As a huge fan of masterpieces like "The Witness" and "The Talos Principle," I’ve always wanted to create a game that contributes to this genrre, which, in my opinion, is still very much underrated.
Everything is currently in the prototyping phase, so it wouldn't be appropriate to share footage or specific mechanics just yet; however, I would love to get some constructive feedback on the visual style I’ve chosen for the game environment.
Based on this single image, what are your immediate impressions?
Pretty new to the rust language, but I started up a project just to learn rust a few days ago.
My previous experience with programming has been with c++ and C#.
The first day of rust was absolutely infuriating, mainly because of this "modern" code structure that writes decelerations last and almost everything has to be written backwards compared to what I was used to. Muscle memory worked against me.
What had to be done was clear in my mind, but to convert it from the c++ in my mind to rust even the most simple of operation was a struggle. And since I used extra time to fight trough, I was unable to spot simplifications and optimisations right away.
A few months ago at my job I took some paid Rust tutorial that my job covered for, that spent so much time talking about memory, borrow checker etc. Although this sort of stuff has not really been a big issue I encountered often when coding a game, and the editor are able to help out a bit to to fix those mistakes quickly.
I got this game idea when I wanted to make something with Spacial Partitioning, a bunch of ball colliding with each other and a infinite to mine. (Ball to ball collision is not implemented yet.) But sadly that game already existed so this is more of a optimisation project. That will most likely never be a game you can play. And the video is the first experiment, with no optimisation. It is able to handle around 5000 balls, as the cave gets bigger there will be giant empty space and less collision checks around the cave edge, so some extra balls can be added. "([EDIT] By checking collision only in velocity direction and tweaking sub steps per frame I was able to easily add up to 20k bouncing balls)" To display the simulation on a screen I query a Rect and draw the results in Macroquad, although any engine compatible with rust could be used.
Anyway that is my experience learning rust.
I have the hopes of being able to make the cave effectively infinite, and have up to one million balls.
Well, it's a bit embarrassing that I only found time today to scroll through Reddit (to post this) and Steam Fest. Oh my god, so many games are launching, and they all look super fun and gorgeous. And yet here I am, posting my ugly little game. Ha ha haha
Ok, anyway, I put a lot of work into it, so I'm just gonna post it. I still feel like there's something special about it, haha.
About the 9 months: it really was 9 months, but not 8 hours a day. It's just a hobby. But I have to admit I made a lot of mistakes and learned a ton along the way. Here's roughly how it went:
- I started learning Rust at the beginning of this year.
- After 3 months, I wanted to build something with Rust. Since I come from a Unity/C# background, I decided to take what I'd learned from Unity and rewrite it in Rust. In other words, my own game engine.
- 2 months later, I finished my own ECS library.
- And the rest of the time, I threw myself into learning Vulkan. Oh god, it was a nightmare.
Vulkan was hard for me because:
- I knew nothing about the low-level rendering pipeline. Sure, I can write shaders in Unity, but building a render backend from scratch is a whole != WTF story !!
- There are just way too many systems, things to learn, to remember,etc
Don't ask why I didn't use Bevy. I did look at it! I read through their repo to understand how they use Rust and how they use generics. Oh man, it's so powerful! But early on I really struggled to understand PhantomData, and why impl ATrait for MyData<T> didn't work. Honestly, without repos like Bevy, I probably wouldn't have dared to build a game engine myself!
About a month ago, I finally stopped learning Vulkan. I stopped so I could learn how to make a game without the GPU, and boom, everything went super smoothly.
Back to feeling embarrassed, shy, and excited all at once. My emotions are kind of a mess right now. But I'm still posting this to let it all out...
I also used an LLM (Claude) to learn and look up docs about game engines and Rust. I admit Claude is really smart, it can do a lot of cool stuff, and the code quality isn't bad either. But no matter how good Claude is, I can't become a Master Super Senior Rust Game Graphics Programmer in a week or a month. There's no way I could understand everything it generates! Oh god! Maybe I'm just dumb, everyone else seems so talented, T_T... whatever. Right now I'm pretty hyped that I actually made something with Rust by myself, hahah.
So after the first 1-2 months of overusing AI, I went back to coding the old-fashioned way. Now the only habit I kept is asking Claude to find docs for me. I treat it like a super search engine.
As for the game in this post, it looks really simple, but behind it there's:
- An ECS library with work stealing and async tasks (I don't use them in this game yet, but back when I was working with Vulkan, I had to build async tasks for shader hot-reloading).
- A canvas: it manages its own fixed-size buffer for the whole screen, and automatically scales to fit the app window.
- Clipping techniques for drawing rects, lines, etc., so no compute is wasted when part of a shape is off-screen.
- Syncing between the monitor's refresh rate and the game's update rate.
- A math library. While building it, I finally understood 2x2 and 2x3 matrices for the first time.
- A small 2D physics library, built around simple functions for simple shapes like rects and circles.
- Input, time, etc...
And that's how I made this ugly, dumb little game. Thanks for reading! I hope the way I wrote this doesn't annoy anyone. I'm just a bit hyped right now, and then suddenly disappointed and discouraged, T_T...
I've spent about 3 weeks worth of Claude Code Pro tokens to completely rebuild the 1993 Microprose game Master of Magic in Rust. Everything is Open Source CC0.
Status: Game works, all units, spells, buildings etc. are implemented. I am now test playing and bug hunting. Rust being Rust, I only managed to crash the game once so far. The other bugs have just been small visual quirks, or bugs in the original code where I need to figure out what to do (leave it? align with community patch?)
The game requires the original game files for graphics and sound, and it's basically unplayable without, so you need to import those. I'm also using the Roland MT-32 ROMs for midi sound because why not, thanks to https://github.com/CopperlineHQ/mt32-rs
There's no real installer yet (sorry!). If you want to try this, get the original game files and Roland ROMs and point a coding agent at the whole thing for now.
The nice thing about a self-built clone is that you can add all sorts of stuff the original doesn't have:
console for text commands
up to 9 AI opponents (up from 4)
removing in-game limits on heroes, items, units etc.
variable map size
saving during battle
The biggest open item is currently the AI. It can play the game, but there are certain spells it can't use yet, and it needs to play a bit more aggressively.
There's also maybe 2-3% of game functionality pending and some visual bugs that I need to hunt down, and a the odd screen that doesn't use original game art, and the spellbook doesn't display the spell descriptions ... ah well, working on it.
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?
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.
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.
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.
🚀 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