Devlog

Moving a whole RTS from Godot to Lobster in four days

This is a pretty crazy one. For the first two weeks Age of Anything was a Godot game, written in GDScript. Since Wednesday it’s written in Lobster, a small game programming language by Wouter van Oortmerssen, and Godot is gone from the project. Not some of it. The simulation, the computer players, the race generator, every 3D model, the baker that turns them into sprites, the lobby, saves, network play, the controller support. About 86,000 lines deleted and about 59,000 lines of Lobster in their place.

Switching engines two weeks into a game is normally a terrible idea, so I want to go through why we did it, how we made sure nothing changed, what was hard, and what it got us.

Why move at all

Godot was fine for drawing the game. The problem was everything the game does that isn’t drawing.

The whole idea is that you type a race and the game prices every unit fairly. Points and cost curves get you most of the way, but the honest test is to make units fight each other. Hundreds of headless fights, at army creation, ideally even mid-match when someone swaps race. On top of that I lean on a lot of tests, whole bot-vs-bot matches, every unit checked in every facing, and the loop needs to stay really tight. GDScript just isn’t fast at that kind of work.

So I asked what Lobster would give us and instead of arguing about it we did a spike. Port only the movement code, replay the same match in both, and compare a hash of the whole game state every single tick. Then combat, then the economy, then the bots. By the end of the first day a 15-minute bot-vs-bot match ran in Lobster identical to the Godot one on all 18,000 ticks.

And it was fast. The full bot match went from 1.48 ms a tick in GDScript to about 0.1 ms in Lobster, roughly fifteen times faster, so fifteen minutes of play took under two seconds. Big battles were 20 to 40 times faster, 2,000 units fighting took 3.3 ms a tick instead of 111. Those numbers were with Lobster’s optimizing compiler, which we later had to turn off (more on that below), but even without it there was no going back really.

The rule: nothing is allowed to change

Once we decided to port it all, the one rule was that the Lobster game had to be the Godot game. Not roughly. Every piece was checked against the original while Godot was still around to compare with.

The sim was checked tick by tick with state hashes, piece by piece: walls you stand on, gates with their own walk lanes per team, siege towers, trade carts, Wonders, webs. The race generator was checked on 23 prompts under three rule sets, notes and hero names included, and came out the same on the first full run. That needed a JSON writer that keeps Godot’s key order and a SHA-256, since Lobster doesn’t have one.

The units were harder. A unit isn’t one sprite, it’s parts put together on a rig and animated, so the old game dumped every draw call of every unit frame by frame and the Lobster version had to match. 14,946 of 14,952 did, and each of the six misses was a real bug, like a war beast’s far legs a step ahead because a file had lost pi’s last digits.

Then the models. Around 440 unit parts, 243 props and trees and 302 buildings and wall pieces, all built in code. They were ported file by file and each one held to Godot’s triangles, then the baker held to Godot’s bakes within a pixel. To get the random bits right (doodads scattered on the map, noise in the textures) we even had to reproduce Godot’s own random number generator exactly, bit for bit, which took testing against its outputs to figure out.

The very first frame of the Lobster game: flat tiles, a rat hut and a few rats

That’s the first frame. Before it there was only black, at three in the morning, because macOS doesn’t hand a frame to a window with vsync on while the display is asleep.

Once everything matched, the checks got frozen into golden files, Godot was deleted and the tests carried on without it.

The hardest part: keeping it the same game

The sim, the models and the baker were the easy part, surprisingly. They had numbers to compare: a hash every tick, triangles, draw calls, pixels. If something was off, a check said so and Claude went and fixed it.

The UI and the controls had nothing like that, and that’s where it really struggled. Claude would tell me the Lobster game was the Godot game, and by every check it had, it was. But the checks only covered what we’d thought to measure. A lot of the game lived in the glue around the match, the code that turns keys and clicks into orders and draws the HUD, and nobody had written a test for any of it. So when it got ported, bits just quietly fell out.

The first time I actually played it, WASD panning was gone. The lines showing where your units are going were gone. The HUD was smaller and harder to read, the music was too loud with no way to turn it down, and Start Battle froze for a couple of seconds before anything happened. Walls dragged out in lines were gone too. None of it showed up in a single test.

In the end we had an agent go through the old client file by file against the new one, just to list what was missing. Most of it is back, but it keeps turning up. Today it was shift-click (gone), the panel growing every time you start training something, and for the first few seconds of a match the food counter drawn so big it filled the whole screen. The humans also looked like they were skipping when they walked, which turned out to be the body standing on whichever foot was lowest on screen, so every other step bobbed up.

I think the lesson is that “identical” only ever means identical to what you checked. For the engine side the tests made parity almost cheap. For the UI it came down to me playing it and noticing, which is slow, and I’m the bottleneck. Next time I’d want every screen of the old game captured and compared before anything is deleted. There’s a lot still to learn there.

What else was hard

Lobster is tiny compared to Godot, and you feel it. There’s no UI system you’d want in a game, so we built our own small one with the timber look. There was no UDP in the build, so finding a game on your home network asks every address over TCP instead. There’s no SHA-256, and on Android it can’t list the folders inside the app, so there’s a table of them. None of it is hard on its own, but it’s a lot of small things you’d never think about in a big engine.

There were bugs in Lobster too. The optimizing compiler read garbage for a global variable in one particular kind of loop, and later miscompiled parts of the model and rig code, so the game runs on the default compiler now. Drawing a rectangle drew it at twice the size, and lines stopped drawing once a custom shader had been used, so everything flat is drawn as little meshes. A recursive sort ran off the stack on a forest ram with 100,000 triangles.

Performance went the same way as the UI. We measured a lot and the numbers were great: ticks fifteen times faster, fights, bakes, match starts. But all of that was measured headless, the sim and the tools with no window. Nobody measured the game itself just sitting there drawing, so we missed that it got worse. My laptop ran hot, which it never did with Godot. Every bit of text on screen made a new buffer on the GPU and deleted it straight after, and on the Mac each delete makes the CPU sit and wait for the GPU. With all the HUD text that was most of every frame, at 120 frames a second because that’s what the screen does. Fixing it in Lobster and capping the game at 60 took the game from about 60% of a core to under 30. Same lesson again really: we got faster at everything we measured, and slower at the thing we didn’t.

Some of the bugs were funny. The ported bots answered raids from six minutes ago, because Godot cleared the list of hits every tick and the port never did. A healer kept walking to an old patient. And the port showed up a real bug in the old game: Godot 4.7 shares packed arrays instead of copying them, so the “copies at the start of the tick” were the live arrays, and units later in the list saw what earlier ones had just done. The comments said otherwise.

What it got us

The obvious one is speed. Races are made from a prompt in about 3 ms and the match start in another 3. Balancing a race by fights works mid-match too. The rats in a test match became forest bears a minute in, got priced against everyone else and carried on, and another time Hellenic moonbears were balanced in 598 fights with their Greek town baked on the spot.

Forest bears gathering berries by their thatched hall, a minute after they were rats

The tests run side by side, four at a time, and the quick batch is about two minutes. Saving a match takes 15 ms.

The less obvious one is that the whole game is now our code, top to bottom, and small enough to change. That’s what got it onto a phone. Lobster compiles to C++ and has an Android template, so an APK was a day of small fixes: the engine needed patching to read textures back on phones and to not lose taps, and we sent those fixes back to Lobster upstream.

Then the first start on Android baked art for minutes, and it was writing 480 MB of pictures to disk to start one match. That made us rethink the bake. Buildings and props don’t depend on what you type, so now they’re baked once and shipped with the game. The part that does depend on you (your units) got moved onto the GPU and only the age you start in is baked up front. A match start went from about 29 seconds of baking to under 5 on the Mac, and buildings went from 134 seconds to basically nothing.

The battle setup on Android: steppe beetles against forest humans on Reed Meadow

Touch plays through the same controls as the Steam Deck, which works really well on a small screen. It looks really great on the phone, I think better than I expected. There’s still work, wide phones and some gestures, but it’s the same game.

The title screen on an Android phone, beetles and humans lined up along the bottom

Was it worth it?

I think so, though it’s early. Godot gave us a lot for free and there’s a cost to not having it: every tool is one we wrote. But the game was already mostly code, the models, the art pipeline, the sim, so what Godot was really doing for us was drawing sprites and running slow scripts. Now it’s one codebase, it’s fast, and the game runs on the Mac, the Deck and a phone.

All of this was built with Claude, my AI, doing the porting through the nights with agents working side by side on the model files, and me steering and playing. I don’t think a solo dev could have done a move like this in four days any other way, and the tick-by-tick checking is what made it safe to try.

I’ve been playing it properly now, and it’s mostly little things left, which are exactly the things the tests never saw. Next is the Mac against the Deck over the network.