cragwind

I recently ported Robocave from F# to Rust as a way to learn and experiment with Rust. The game is a first-person shooter originally written for 7dfps and PROCJAM using a custom F# engine based on Garnet and Veldrid.

Rust version web demo

alt text

In the game, you run around a cave of blocky destructable terrain shooting at robots and evading their shots. It’s just a tech demo for now, but I plan to add more combat and exploration gameplay. Before adding more gameplay, I wanted to try out Rust and see how well the DIY engine approach could work for the project. So I ported most of the existing F# code to use as a base.

F# and Rust have a lot of similarities in their safety features and syntax, so I hoped the process wouldn’t be too painful. My F# codebase already respected ownership concepts and didn’t really use a lot of dependencies or exotic or high-level language features. I also intended it to be largely a port of existing features rather than a grand rewrite and feature creep.

Constraints

For the sake of learning, I wanted to see how far I could go with simple code constructs. My goal was to minimize these elements, at least until I’d exhausted alternatives:

Those are all generally fine in the right situation, but some can either introduce complexity, compromise on safety, or have an effect on compile-time or runtime performance.

ECS

For simple F# games I use Garnet, an entity-component system (ECS) library I wrote along with various helper code for graphics, audio, etc. The library originated from Triverse code, where I needed to iterate over 2D cell components stored in chunks, which explains some differences from other ECS libraries.

For Rust, I didn’t have the same design constraints and just wanted something simple I could crank out in a few days. There are tons of existing Rust ECS libraries, but I didn’t want to take on a complex dependency and deal with its quirks.

So I just went with paged storage with bit vectors and code to join and iterate efficiently. Learning about iterators and getting all the syntax correct took the most time. I have no idea how it performs compared to other libraries, but it hasn’t shown up in my profiling. It’s all static, so there’s boilerplate code to define the various tables I use, but that code is miniscule compared to everything else.

Garnet also includes actor-like messaging, which I wanted to continue doing in Rust. There are many parallelism libraries for Rust, but again for the purpose of minimizing dependencies and learning the language I chose to roll my own. It’s basically just the Rust book multithreading chapter with some extra niceties. This was also the only place I needed to use dyn (for abstracting actor handler code).

Graphics

Winit is the Rust go-to for windowing, which means it handles most of the cross-platform details related to getting a window on the screen. After that, it’s up to you to draw to it using WGPU or other means. Winit also supports web builds, where the window is an HTML5 canvas.

Veldrid has served well as a graphics library that abstracts the various backends using higher-level Vulkan-like primitives (buffers, bindings, etc). I was able to swap in WGPU without too much trouble, although I certainly had to go through the typical blank screen sessions and incrementally build up the new code by mixing the existing Veldrid code with the WGPU examples. For shaders, I translated the GLSL code to WGSL.

Audio

I originally checked out rodio, but I ended up going with Fyrox audio instead because I found it easier to use. You can conveniently use the library separately from the larger parts of Fyrox, which I wasn’t using because I already had my own rendering code.

UI

The F# version didn’t really have a UI other than some overlays and a menu, but I needed something more substantial to support an inventory and other upcoming features. I chose to roll my own terminal UI (TUI), and after a few iterations, I came up with an approach of sending UI draw messages from the game logic actor to the graphics actor in a way that vaguely resembles the Elm architecture.

The TUI cheats a bit by allowing images, but everything else is fixed-width text. It supports drag-and-drop, tooltips, and a few other features needed for an inventory, but developing it further isn’t really a priority right now. Eventually, I’d like to have actual terminals in the game for the player to interact with.

alt text

Voxels

As expected for a voxel game, there’s a bunch of code dedicated to voxel encoding, client/server chunk management, generation, occlusion, streaming, and storage. All of this code comes from prototypes I’ve built over the years in F#.

The voxel code was relatively easy to port because it’s mostly just low-level functions and structs with minimal dependencies and no system calls. To convert to Rust, I’d often do a pre-pass of find/replace on batches of code before going line-by-line.

However, some parts where I wanted to remove pooling required more significant changes to the code. For this, having existing unit tests was invaluable, even if the test simply calculates a hash of the result and compares it against a golden value. Since the ported code wasn’t drastically different, I could typically find any divergence by debugging the two implementations side-by-side.

Networking

So far the game doesn’t expose actual networking, but the F# version internally runs a logical authoritative server with client-side prediction (Quake3 approach where only player inputs are sent). To use the actual network, we need serialization and the means to send reliable or unreliable messages over the network.

For F#, I prototyped using Lidgren for UDP reliability. For Rust, there are plenty of serialization options and a handful of game-centric networking libraries, but none stands out as dramatically ahead of the others. There’s also the consideration of whether to use UDP (native only) or to attempt something with WebRTC/QUIC (web). For the sake of proceeding, I’m starting with UDP sockets from the standard library with KCP for reliability.

Feedback loop

Fast feedback is super important for prototyping and productivity in general. F# interactive is useful for this, although it takes some effort to make it work well to iterate on a game. For Rust, I anticipated compile times might be a problem, but it turned out fine with some care about dependencies and code patterns. For many changes, I could recompile and see results within five seconds on an M1.

For Rust debug builds, I found it helpful to specify opt-level = 1 instead of no optimization because it dramatically increased the speed of the chunk processing code, making it faster to load and test interactively. If I really did need to step through the code using the debugger, I switched optimizations off entirely.

The Rust crate divisions roughly mirror the F# projects, where there’s a set of generic engine-level libraries (numerics, graphics, audio, ECS, etc), then a set of game-specifc libraries (types, core, server, client, frontend, etc). This division worked well to reduce F# compile times through parallel or incremental compilation, and Rust also benefits from it.

Performance

To profile in F#, I relied on dotTrace, Visual Studio, or my own metrics/timing logging to find performance hot spots. With Rust, I used cargo flamegraph and later Tracy.

alt text

Flamegraph is useful to get a quick high-level view of where the time is going proportionally, but it doesn’t produce exact timings. Tracy requires instrumentation in the code, but it records and visualizes spans in a timeline with exact timings to the nanosecond.

Comparing the two versions on my five-year-old laptop:

However, it’s not an entirely fair comparison because I’ve only begun to optimize the Rust version, plus it has about double the draw calls (rendered chunks) and a shorter thread sleep duration in the main loop (spin_sleep).

Aside from looping and draw calls, the most critical performance factors are likely the various macro- and micro-optimizations (like occlusion culling to avoid extra chunk generation, optimized lighting calculation, etc). Those aspects don’t vary so much from one version to the other, except for having additional pooling code in F# to minimize allocation and GC.

Rust assessment

After 35k lines of Rust code, I’m generally happy with the language and ecosystem, although there are still many features I’ve barely touched. The promise of safety and performance seems to be holding up, and I’m similarly productive with Rust and F# (although some portion of that may be due to working on an M1 versus a slower machine previously).

On features, I find macros difficult to read and write and have generally avoided writing my own so far. Tuple joins in the ECS code are an obvious candidate for a macro that I might revisit if there’s ever a need.

Proper Result handling is something I know I should do, but I often just go with unwrap() or expect() as I’m quickly trying to get something prototyped. I suspect that will change as code stabilizes and I learn how to define and use Result types with less friction.

Compared with Rust, F# avoids a lot of borrow-related noise in its syntax, not to mention it has many more places where type annotations are optional. However, I’ve gradually steered toward more explicitness, so including types is fine with me.

With F#, I can write low-level code where needed and more idiomatic or higher level code where performance is less critical. With Rust, the idiomatic code may have a performance edge (and safety when considering borrowing), but overall there’s a higher level of cognitive overhead in caring about references and borrowing (yes, you should care about that anyway, but not always to the level of detail and workarounds needed to satisfy the Rust compiler). So both can achieve similar results but by different tradeoffs.

Results

I reached feature parity after a few months, then continued to build and refactor the Rust version. The versions are similar in overall structure and identical in many of the low-level algorithms. The Rust version has more gameplay features and about 25% fewer lines of code, probably due to simplifying the ECS code, removing GLTF, not porting chunk persistence, and removing pooling (so again, not really an apples-to-apples comparison).

The Rust version turned out surprisingly static, at least compared to prior code I’ve written that tended to have more abstractions (interfaces, callbacks, etc) or some form of event dispatch. This is probably due to slightly higher friction for abstractions and my intent to be minimalist. And after foregoing the dynamic event dispatch I used in the F# code, the Rust code was quite easy to follow.

After zipping, the F# version was 16 MB versus the Rust version weighing in at 3 MB. The F# version has 14 separate files (exe + dlls + config + assets), but the Rust version is only a single executable file with assets embedded (they can also optionally be separate for modding or larger assets). I was able to target WebAssembly with Rust, although I believe that could also be possible in F# if Veldrid supported it.

I feel like this kind of DIY indie game could be written in either language depending on the author’s preference and familiarity, especially given that the performance is roughly on par. The more important factors are the game itself (fun factor, features, content), how it’s written (DIY versus 3rd party engine, design, algorithms), and target platforms. For now, I plan to continue using F# for my other projects and use Robocave as a Rust testbed.