DEV Community

Tom Muga
Tom Muga

Posted on

# Bypassing the Marshalling Wall: 4 Iterations of a Single-Binary Godot + .NET WebAssembly Architecture

This started as a bounty. Official C# web export support doesn't exist in Godot 4.x, embedding the full .NET runtime inside the engine's WASM build hits real, structural limits, and someone had put money on solving it. Partway through, the bounty stopped being the point. I kept going because the problem itself was worth solving. Four iterations later, I'm still not fully done, but each version taught me something the last one couldn't.

v1: Shared memory, fixed offsets, a Worker

The first version ran Godot (C++) on the main thread and .NET (C#) in a Web Worker, talking over a single SharedArrayBuffer with a rigid, hand-designed memory contract of fixed offsets. I even built a generator for it: a JSON schema describing struct fields, entity counts, and buffer layout, producing a matching memory_layout.h on the C++ side and a Bridge.cs on the C# side.

It worked, technically. It was also bad in every way that matters. This was my first attempt and my first time in Godot, and I didn't yet understand how the engine actually worked under the hood, so the whole approach was strange in retrospect. C# could only do game logic math; it couldn't set a node or enter the scene tree. Basically, a terrible first version.

It was when a contributor pointed out that there was no GodotSharp-style API that I decided to explore building GodotSharp-like bindings myself. That led to the second iteration.

v2: A fuller generated API, still over shared memory, still a Worker

The second version kept Godot on the main thread and .NET in a Worker, but replaced the crude fixed-offset buffer with a proper command-based protocol: a command dispatcher and processor pair. C# submits a command, it crosses the shared buffer, and the C++ side dispatches and processes it against a much more complete, generated API surface than v1 had.

A Pong demo actually ran on this version. But it stuttered, visibly and consistently. Godot (C++) and C# communicated via shared memory through JavaScript. Both sides needed helper code to write directly to shared memory: once a command was written, C# would set its status to pending and poll for a result. C++ would pick it up, route it to the right handler, read the data at the offset, and pass it along as arguments.

What ended v2 was that Pong demo. It was genuinely slow, and you could see the stutters. I tried what I could, but the performance penalty was substantial given how much happened on every single crossing: polling, status checks, all of it. That led to version 3.

v3: Same thread, direct calls, no command buffer at all

Version 3 was the trickiest of the four. I still kept two WASM runtimes in the browser, but this time had Godot export Emscripten functions directly. JS handled the marshaling, and C# received the results. No more command IDs, everything became direct.

Performance improved compared to v2, but getting there was not easy. Early on, the runtimes were so nondeterministic that I genuinely couldn't predict what a given run would produce. Sometimes it crashed with function signature mismatches; other times it just went silent, and occasionally the ball would move and then crash. C# in the worker had access to the Godot module and could call into it, but Emscripten workers can't execute main-thread-only functions, and that limitation was the root of most of the chaos.

The first fix was replacing lazy exports with eager ones, exporting everything up front instead of relying on lazy-loaded function wrappers. That alone made things noticeably more stable. The second fix was adding asserts to the Godot WASM build, which shed a lot of light on the silent crashes. It turned out to be exactly what I suspected: main-thread-only functions being called from the wrong context. Once I could actually catch those violations instead of failing silently, the fix was simple: move .NET onto the main thread. Since .NET called Godot functions through JS anyway, that worked cleanly.

Another issue: passing large values. Object IDs like QuadMesh IDs could be huge negative numbers that lost precision crossing from C# to JS. My first fix was splitting every ID into low/high 32-bit halves and reconstructing them on the JS side before passing them to Godot, but that reconstruction was too costly in hot paths. Eventually I found a cheaper fix: instead of passing the actual numeric ID, pass the uintptr_t pointer value. It's smaller, survives all the boundary crossings intact, and everything worked smoothly and cheaply from there.

After that, more research made it clear that JS marshaling, however optimized, isn't enough for real production games. That pushed me into version 4.

v4: the wall I'm at now

Version 4 is interesting. I linked Godot's WASM object files directly into .NET's WASM build instead of keeping them as separate runtimes. This was the easiest migration of the four, both now exist in a single runtime, and it's been smooth so far, but I'm uncertain how it will hold up under real games.

Here's the open question: the moment WasmEnableThreads is true in .NET, .NET runs as a worker (Microsoft's Deputy Thread model). I'm still investigating the implications of that, especially on performance.

I don't have that answer yet. If you've worked on runtime threading models, WASM or otherwise, I'd genuinely like to hear where my thinking is wrong.


About the Author

I'm a software engineer based in Nairobi. If your team needs help shipping, debugging, or improving your software, I'm available for remote contract work. Reach out at tommuga@gmail.com or check out my work on GitHub: github.com/tommygrammar.

Top comments (0)