In an earlier post about picking a game engine I claimed that I try to write the core of the game in an engine-agnostic way. The post ended on a hedge — in theory I could switch engines, in practice I did not believe it would be easy. Time to actually find out.
So I wrote a copy of the Godot project in three.js. If the claim is true, everything should work. A couple of Claude sessions later, three.js is running. It more or less works — buildings render, you can click them.
You can poke at the live port here: /demo/.
What the three.js port looks like
The whole web frontend is one HTML file: site/static/embeds/world.html, around 440 lines, with three.js (r167) and a couple of utility modules vendored next to it. Four .glb files under Assets/Meshes/ carry the architectural parts — chimney, door, window opening, gable roof.
Roughly:
- scene, camera, WebGL renderer (DPI capped at 1.5× so high-DPI screens don’t melt the fill rate)
MESH_MAP→ GLTF loader pulling named meshes out of the.glbfiles at startup- a material pool keyed by the same
MaterialTypeenum the C# side uses (WorkerBrick,MerchantRoof,Water, …) - one
InstancedMeshper(meshType, materialType)pair — the same trickMultiMeshBatcherdoes on the Godot side applySnapshot()rebuilds the three layers (buildings, streets, rivers) from a JSONWorldSnapshot- click → raycaster picks the instance, JS calls back into .NET with the actor id, side panel populates
- an orbit camera in spherical coordinates: drag to rotate, shift+drag to pan, wheel to zoom
The bridge is the interesting part. The simulation itself runs in the browser via Blazor WebAssembly — the exact same C# code that runs under Godot on desktop. WorldBridge.GetWorldSnapshotJson() is a [JSInvokable] method that walks the profile manager, calls the same RenderingSystems.BuildBuilding / BuildStreetGraphRenderData / BuildRiverRenderData functions Godot uses, and returns the result as JSON. JS pulls a fresh snapshot on each tick. Tick notifications are throttled to ~10 Hz so the browser is not drowning in interop calls.
There is no second simulation, no port of the game logic. The web client and the desktop client are the same brain looking at the world through different eyes.
What had to change to make this possible
The abstraction did not survive contact with reality unmodified. A few things had to move before three.js could even start:
RibbonMeshUtilsmoved out ofCottonopolis/Rendering/intoGrandStrategy.Core/Rendering/. Ribbon mesh generation (the thing that turns a street centerline or a river path into a triangle strip) used to live with the Godot renderer. To share it with the web client, it had to be rewritten against engine-neutral types: a newTriangleMeshDatarecord, aVector3that grewDot,CrossandLengthSquaredmethods it never needed before, and aGodotMeshAdapteron the Cottonopolis side to translate the neutral mesh back into a GodotArrayMesh.Presentation logic had to be lifted out of the Godot UI. Formatting an actor’s details for a side panel — “what does this building look like in human-readable form” — lived inside Godot UI controls. The web side wanted the same strings without dragging Godot’s UI into the browser. That logic now lives in
PresentationSystems, returning plainActorDetailsDTOs that either client can format.Tick rate became a real concern. Desktop Godot is perfectly happy to be notified on every simulation tick; the browser is not. A throttle plus a world-version hash were needed before the JS side stopped repainting work it had already drawn.
None of these were intellectually surprising — they are exactly the kinds of leaks one would predict an “engine-agnostic core” to spring. They were just invisible until something other than Godot tried to consume the core.
What’s still missing for feature parity
The geometry is there. The polish is not.
Rendering. The Godot client has triplanar textures and normal maps on streets, a custom water shader on rivers, SSAO, glow/bloom, a procedural sky, shadows, depth of field. The three.js client has MeshLambertMaterial with a flat color, and streets and rivers come through as flat LineSegments rather than the proper ribbon meshes the data model can already describe.
Input. WASD pan, depth-of-field tuning — none of that exists on the web yet. Mouse-only.
UI. The Godot HUD has dynamic panels with open/close animation, a theme system swapping colors and fonts, pinning, collapsible sections, a responsive layout. The web HUD has a static sidebar with a <dl>, a close button, and an FPS counter.
Audio. None. The Godot side has a pooled UI sound manager; the web side is silent.
And then there is the code that is engine-coupled by design and never pretended otherwise: StreetRenderer builds Godot StandardMaterial3Ds with triplanar shaders and normal maps, the post-processing stack (SSAO, glow, shadows, depth of field, procedural sky) is Godot’s, UISoundManager is a Godot.AudioStreamPlayer, and the UI panels inherit Godot.Control for the primitives even though the theming, animation, drag and section-state logic on top is plain C# I wrote.
The asset pipeline, on the other hand, is already portable: the same .glb files are loaded by Godot and by GLTFLoader in three.js — no separate web export step. Camera and input concepts (orbit math, mouse drag, wheel zoom) are standard across engines too; only the bindings differ.
So, do I really need Godot?
The three.js experiment is a clearer answer than I expected to a question I had not quite asked. Rendering data — the geometry, the per-instance transforms, the material assignments — really is engine-agnostic. The Godot client and the web client share that data path and it works. The asset pipeline turns out to be portable for free, because glTF is glTF. That is a real result and not nothing.
What is not portable, and would not move with me to another engine, is narrower than I would have guessed: the material/shader stack (Godot’s StandardMaterial3D with triplanar and normal maps), the post-processing pipeline, audio, and the lowest layer of UI primitives. Camera and input bindings I could rewrite in an afternoon.
Which puts the choice on a sharper edge than the original engine post did. Staying on Godot means accepting that “engine-agnostic” stops at the shader-and-post-processing boundary and Godot is the canonical frontend that gives me materials, audio, and UI primitives for free. Going to Silk.NET means owning all of that — but the rendering data work, the asset pipeline, and most of the camera/input/UI logic I have already written would survive the move.
I do not have an answer yet. The point of this experiment was to find out where the line actually was, and now I know.