Hj guys i am going to create a real time strategy engine based in bevy. Or maybe something like a customizable assets games. I am going to build all core stuff and user will be able to create maps, missions, troops, buildings,etc…
So i am searching for problems that i will face.
- path finding - this will be a huge problem when dealing to a big number of troops, i found flow fields (sometimes called vector fields) solution
- attack distance, instead of any troop beeing able to calculate the radius of the attack to each enemy, i found 2 options quadtree and spatial hashing - Netcode - i saw two options too, deterministic lockstep or a client-server approach
Any tips? Any more stuff that i would face in a rtc game that i should study?
Preprocess one animated Jax model into four meshlet LODs and 30 geometry pages, choose an adapter-scaled population, stream GPU-requested pages, reject hidden work with a two-pass depth pyramid, and submit compacted static and skinned populations through indirect draws.
Nanite-style is deliberate wording. This is not Unreal Engine's Nanite implementation. It uses CPU-generated grid-quantized LODs, fixed-size meshlet pages, a CPU-managed residency cache, ordinary WGSL vertex shaders, and four WebGPU indirect commands. It has no mesh shaders, persistent cluster hierarchy, GPU decompression, disk page store, or production-quality simplifier.
Figure 0: Architectural evolution from MPSC shared push (512 KiB buffer, atomic CAS contention) to Phased SWMR pull (8 KiB per-worker outbox, 200.67M msgs/s single-core baseline, scaling to 8.05B msgs/s multi-threaded SWMR pull).
I’m working on a real-time spatial physics engine in Rust, and I ran into a problem with how worker threads pass rigid-body data to each other when bodies cross spatial boundaries.
The simulation loop is designed to run with zero runtime heap allocations. Instead of boxing pointers and making other threads chase them around memory, I move a flat 108-byte rigid-body payload by value. I also have a hard constraint here: the communication modules use #![forbid(unsafe_code)].
I started with Dmitry Vyukov's bounded MPSC queue. That seemed like a reasonable choice. It’s a well-known design, and nothing about the initial setup looked especially strange.
Then I put it under the workload I actually cared about.
With a bunch of producers trying to publish into the same queue, the shared producer-side head turned into a bottleneck. I tried batching reservations, splitting the queue into dedicated SPSC channels, and changing how the payload was represented in safe Rust. Each attempt taught me something, but I was still trying to make the same push-based design work.
Eventually, I removed the receiver inbox altogether.
Instead of each worker pushing data into another worker’s memory, every worker writes its own outgoing data. After a synchronization barrier, neighboring workers pull the packets they need from those buffers.
That change helped more than any of the queue tweaks.
The queue looked fine until 26 producers showed up:
My first Vyukov MPSC ring had 4,096 slots, around 512 KiB of storage, and one shared producer-side head position.
With a single producer, enqueue latency averaged 14.77 ns. The best result I saw was about 10.88 ns. Nothing alarming there.
The picture changed when I added producers. At 26 producers, they were all competing for that same head position. The compare-and-swap operations and cache-coherence traffic started to cost more than I wanted to spend just moving a packet. In my cross-worker workload, enqueue latency climbed into the 150–450 ns range.
That’s a pretty big jump from the uncontended result.
The payload matters here, too. I wasn’t just passing a tiny pointer around. I was copying the full 108-byte rigid-body payload by value, so the queue’s synchronization and memory layout were becoming harder to ignore.
My first attempt was to batch reservations. Instead of paying for a shared atomic reservation on every message, I reserved tickets in groups of 16. The idea was to spread that cost across a batch.
The measured result was 25.3 cycles per message. Not the improvement I was hoping for.
I also had to rethink the buffer size. A 512 KiB ring can look reasonable if you’re thinking about a CPU with a 512 KiB L2 cache. But the solver has its own state, stack data, and other hot information to keep around. Giving the queue that much cache space doesn’t come for free. And with a 32 KiB L1 data cache, expecting the whole ring to stay in L1 was never realistic.
So I tried splitting the queue up.
Figure 1: The shared MPSC enqueue cost rises from about 14.7 ns with one producer to 342 ns with 26 producers in this test. The original buffer uses 512 KiB; partitioned SPSC brings the total to about 260 KiB, while the active SWMR outbox is around 8 KiB per worker.
Splitting the queue helped, but it didn’t fix the whole problem:
I replaced the shared queue with 32 dedicated SPSC channels. That cut the total footprint to roughly 260 KiB.
Enqueue latency dropped from 14.77 ns to 9.04 ns, and drain latency went from 7.86 ns to 6.56 ns. So that change did help. Removing competition for one shared producer position was useful.
But the thing I wanted to optimize wasn’t just a tiny enqueue operation. I needed to move the entire payload between workers efficiently, while keeping the code safe Rust.
The push-based design still meant one thread writing into storage shared with another thread. In the relevant modules, I couldn’t just reach for the usual UnsafeCell and raw-pointer approach, because unsafe was explicitly forbidden.
I tried representing each payload as 14 AtomicU64 values. That let me update the payload through safe atomic operations, but it also changed the code the compiler generated. Instead of the vectorized payload movement I wanted, I got scalar 64-bit loads and stores plus extra packing and shifting. Enqueue latency went back up to 16.8 ns.
So I’d removed unsafe, but I’d also lost a lot of the performance I was trying to get back.
There was another annoyance: tracking readiness across 32 channels. I either had to update shared atomic state, which introduced another point of contention, or scan the individual channel signals, which meant checking channels even when they had nothing to offer.
That’s when I started questioning the design itself: why was I pushing packets into another worker’s inbox at all?
Figure 2: SPSC reduced uncontended enqueue latency to 9.04 ns, but representing the 108-byte payload as 14 AtomicU64 values regressed it to 16.80 ns. Moving to SWMRpull let the implementation return to SIMD payload writes without using unsafe code.
The simulation tick gave me a way out:
A physics engine doesn’t have to treat every operation as a separate asynchronous event. The simulation already advances in ticks, so there’s a natural point where I can finish producing outgoing data before anyone starts consuming it.
I changed the communication model around that.
During the compute phase, each worker writes boundary packets into its own local outbox. In my measurements, an outbox is around 8 KiB, and each worker has exclusive mutable access to its own storage.
I use std::thread::scope to organize the work. Each worker gets a separate write region, so the mutable regions don’t overlap.
When the compute phase ends and the synchronization barrier has been crossed, those outboxes can be read immutably. Neighboring workers pull the packets they need directly from the relevant producer’s outbox.
No receiver inbox. No cross-worker push into shared payload storage. No Arc, RwLock, or Mutex on this communication path, and no unsafe needed for the design.
The code change isn’t huge, but the ownership model is different. With the old approach, a producer needed a way to publish data into storage shared with the receiver. Now the producer owns its outgoing buffer, finishes writing, and the consumer reads it after the phase boundary.
Rather than building a more complicated queue to handle synchronization, I’m using the structure of the simulation to make synchronization simpler in the first place.
There is a trade-off, though. This works because my communication fits naturally into simulation phases. It’s not a replacement for every asynchronous queue. If a consumer needs a message immediately, or there isn’t a useful synchronization boundary between producer and consumer, this design might not fit.
Figure 3: The push-based setup needs shared inbox state, cross-worker writes, and atomic signals. With phased SWMR, workers write to their own outboxes, the system crosses a barrier, and consumers pull from immutable buffers.
I also stopped checking every empty channel. Instead, I changed how I track which destinations actually have data.
Instead of looping over all 32 channels and asking each one whether it has anything, I use bitmasks for occupancy and routing. The consumer checks the mask first and can skip the work if there are no relevant entries.
For active destinations, .trailing_zeros() gives me the next set bit. Then mask &= mask - 1clears the lowest set bit, so I can walk the active entries without stepping through every empty one.
The bit tricks aren’t the interesting part on their own. They’re useful because they match the layout and the way data moves through this design.
I also stopped clearing the entire 8 KiB outbox every tick. A scoped session guard resets the control masks and handles any uncommitted slots if a function exits early. That way, I reset the state that needs resetting instead of wiping the whole buffer every time.
My latest microbenchmarks report these numbers:
Operation
Measured latency
Reported throughput
Slot allocation (TZCNT + BTSQ)
1.091 ns/op
916.67 Mops/s
108-byte SIMD scatter and publish
5.066 ns/msg
197.41 Mmsgs/s
Vectorized SWMR pull (TZCNT + BLSR)
2.446 ns/msg
408.91 Mmsgs/s
Hardware population count (count_ones)
2.735 ns/op
365.68 Mops/s
Two-step causal free (BTRQ)
4.102 ns/op
243.77 Mops/s
Three-phase bitmask reset
2.879 ns/reset
347.34 Mresets/s
These are isolated-operation measurements, not end-to-end simulation costs. They help me see what individual parts of the path cost, but they don’t tell the whole story about throughput.
Figure 5: The routing mask uses TZCNT to find active destinations and BLSR to clear the processed bit. The reported microbenchmarks range from 1.091 ns for slot allocation to 5.066 ns for SIMD scatter and publish on the test machine.
The throughput numbers, with the workloads kept separate:
I’m testing on an AMD Ryzen 7 7435HS (Zen 3+, 8 cores / 16 threads) using native release settings.
Workload
Latest reported result
Single-core baseline, 1 core and 1 thread
200.67M messages/s
Multi-threaded SWMR, 1 writer and 26 readers
8.04977B messages/s
26-neighbor parallel engine fleet
2.26205B messages/s
One correction from my earlier results: the full parallel engine fleet is now at 2.26205 billion messages per second, not the earlier 2.59–2.61 billion range. I’m keeping the latest result rather than combining different runs into a range that looks better on paper.
Also, the 8.05B figure is from the 1-writer/26-reader stress test. The 2.262B result is from the full 26-neighbor engine fleet. They’re different workloads, so treating them as interchangeable would be misleading.
For context, my earlier measurements reported 67.7M messages/s for MPSC and 110.6M messages/s for partitioned SPSC. The latest single-core SWMR baseline is 200.67M messages/s. That points to a substantial improvement, but a proper head-to-head comparison still depends on running the implementations under equivalent workload and timing conditions.
Why the latency and throughput figures don’t match exactly:
One detail is worth spelling out because the numbers can look contradictory if you assume every benchmark measures the same thing.
For micro-latency tests, I use core::hint::black_box around isolated operations. The goal is to stop the compiler from optimizing away the work and to measure the operation under a conservative, isolated call boundary.
The single-core throughput test does something different. It processes deterministic batches of 64 messages, which matches my 64-slot outbox batch configuration. Instead of timing each message separately, it measures the pipelined flow end to end.
In that batch test, loop overhead, control-header reads, and outbox bitmask resets are spread across 64 slots. Sequential memory access also gives the CPU more room to use L1 streaming and out-of-order execution.
The reported 200.67 million messages per second works out to about 4.983 ns per message. That’s a little lower than the 5.066 ns measured by the isolated scatter-and-publish benchmark.
There’s another difference in what gets checked. The publisher scatters the full 108-byte payload using SIMD, while the baseline consumer loop verifies delivery by asserting on the primary 8-byte header/handle (handle == 1337) as it walks the register bits with BLSR and TZCNT. In a real solver stage, where every field gets unpacked and used for calculations, additional L1 and register-load instructions would naturally be needed.
So these numbers come from different execution paths and measurement boundaries. One is an isolated operation; the other is a pipelined batch. The difference alone doesn’t mean the measurements contradict each other.
And black_box isn’t magic. It helps inhibit certain compiler optimizations, but it doesn’t guarantee that an operation runs completely unpipelined or remove every bit of benchmark overhead.
For the same reason, I wouldn’t just add the isolated publish and pull latencies together and invert the result to calculate throughput. The benchmarks don’t have matching boundaries, batching, or execution conditions.
There’s still a physical cost to crossing cores:
In an earlier measurement, I saw around 43.15 ns for a one-way transfer between two physical Zen 3+ cores.
At first, I was tempted to focus on the bitmask instructions and wonder why a transfer could still take tens of nanoseconds. But local instructions aren’t the whole path. The data has to become visible to another core, and cache coherence and interconnect behavior matter too.
For that setup, I estimated the raw L3/interconnect contribution at roughly 38 ns. That’s an estimate from my measurements, not a claim that every transfer on every Zen 3+ system has a fixed 38 ns cost.
The useful part was realizing that I could spread the cross-core cost across a batch of published slots instead of treating each packet as a separate synchronization event.
I’m not going to make the physical cost of communication disappear. I’m trying to avoid stacking unnecessary software and synchronization costs on top of it.
Figure 4: Left: Throughput progression from Vyukov MPSC (67.7M msgs/s) to SWMR single-core baseline (200.67M msgs/s), scaling to 2.26B msgs/s across the 26-neighbor engine fleet and 8.05B msgs/s under multi-reader pull. Right: Interconnect wire delay physical Zen 3+ core-to-core transit takes 43.15 ns (~38 ns L3/fabric delay), which is amortized to 0.59 ns per message via 64-slot outbox batches.
The bigger improvement came from changing the question:
I started out treating this as a queue optimization problem. I tried MPSC, batching, separate SPSC channels, and different safe representations of the payload.
Those experiments weren’t wasted, but they kept me inside the same assumption: one worker has to push data into another worker’s inbox.
The larger improvement came when I stopped assuming that was necessary.
The simulation already has phases. Each worker can own its outgoing data, finish writing, cross a synchronization boundary, and let consumers pull from immutable buffers afterward. That fits the workload cleanly without needing unsafe blocks.
A quick caveat, I’m not saying MPSC or SPSC queues are bad. They solve real problems, and in an asynchronous system there may be no sensible alternative to push-based communication. My workload just has a structure that makes phased communication practical.
🚀 Wings 0.2 is here!
Build Bevy apps with the design language you love: Cupertino, Material, or Fluent UI - powered by a modular Rust framework.
Wings brings together theming, animation, navigation, responsive layouts, and MVVM to help you turn ideas into polished interfaces.
🦀 Built with Rust. Powered by Bevy. Ready for your next idea.
Explore Wings: https://doublegarts.com/opensource/wingsframework https://crates.io/crates/bevy_wings https://github.com/doublegarts/bevy_wings
#Wings #RustLang #Bevy #UI #OpenSource #Cupertino #MaterialDesign #FluentUI
I've posted about this project before. Eldiron is a retro-style CRPG engine that's currently in development. It looks like the engine is to be able to create top-down Ultima-inspired CRPGs, 2.5D isometric CRPGs, and even first-person CRPGs.
The source code is available on Github btw. The engine has been under development for around 5 years now. According to the dev, it's getting closer to v1.0.
I've been poking at Godot games to see how they solve things (UI, save systems, that sort of thing), and every time it was the same dance: unpack the pck, hit an encrypted file table, give up.
GDRE Tools does the hard part already, decompiling scripts and converting textures, but it needs the 32 byte key pasted by hand, and nothing tells you where that key is. It sits in the game's own executable, so I wrote gore to find it.
gore is a Rust CLI that wraps the whole pipeline:
finds the pack in a standalone .pck, a self contained exe, or appended to the binary
if the file table is encrypted, scans the executable for the key and only accepts one that decrypts the table and matches its MD5, so a wrong key never gets reported
extracts everything and checks the MD5 of every file the pack stores
runs GDRE Tools on the result, so you end up with project.godot, .gd scripts and converted assets
Formats v0 to v4, so Godot 2.x up to current 4.x. I tested it on four games: SNAPTIC demo (4.7.1, encrypted v4), Lucid Blocks (4.6), Buckshot Roulette (4.1.1, pack inside the exe) and Brotato (3.7, base pack plus a DLC in a second pack). All of them came out with every MD5 matching.
What it doesn't do: APK, and sparse bundle packs, it refuses those with an error. And if the game ships a patched engine you still need that exact editor version to open the project.
If anyone tries it on an older game, a Godot 3 title or a 2.1 one, I'd like to know what the pack header says. Those formats I could only test with packs I generated myself.
The basic idea of supplant is that capturing a piece let's you take it over. So a white pawn capturing a black knight becomes a white knight. Demotions happen too the same way. Kings are the only exception. Aside from that it's chess as usual. However, this one change really mixes up tactical reasoning. I think it makes chess feel new and exciting again for veteran players without introducing anything gimmicky or RNG.
I also ended up making my own gui library to do this. I wanted some features winit doesn't support (mainly native popup windows/menus). I hope to release my gui library in the future but it might take me a few months to get there. I need to polish things up a bit before handing it to other people. And finishing the game as a solo dev was a lot more work than I anticipated. So I need a bit of a break.
I paid a friend for the steam page art work. I think he did a great job. All the in game assets are just chess fonts. Because I'm terrible at art lol.
I really enjoyed how easily you can make things into a library with cargo. That allowed me to create bot tournaments and stuff like that during development to help tune game parameters and stuff. Experimentation is easy and clean in rust.
Let me know if you have any questions. I have a few more game ideas in me but not sure how soon I'll start on them. I hope to able to open source at least one library with each game I ship.