Building Districted · Part 1

Districted: Ground floor

Building a city simulation: specified below ground, seen above it

·9 min read

Growing up, city builders were among the games I (let's say) 'invested' my time in. I loved the journey of starting from a blank slate and building a thriving city with complex systems underpinning it. My career has taken me towards building complex distributed systems, and I wanted to see what it would take to build such a system ground up today. This wouldn't be possible without the advancements over the past few years and it turned out to be a great experience. This is a blog about my experience of building it; what I learnt about city builders and how I interacted with another complex system - Claude code - to create the game.

City builders are complex systems that couple in interesting ways to recreate realistic scenarios for players to navigate. Building the game engine leveraged a significant amount of my experience in large, complex systems. The UI is where I learnt to blend my taste with best practices and standard conventions. With over a hundred commits, and a few versions of the engine and renderer checked in, I felt it was a good time to write down the experience. The game is not finished, and may never be. If you want to play it, you can find it at districted.rajeshwar.dev.

Cities built to last

City builders are a blend of abstractions, modeling and modularity. The abstractions are obvious: trees, land, grass, houses, water - these are abstractly represented as objects or entities. The systems such as utilities, taxes, income, pollution, livability - are all models that are interdependent in very precise ways, and have an effect on the trajectory of the game. The modularity is structural between these three, but also, crucially, between the UI and the engine.

The systems and models are below the ground. Every tile, every district, every citizen relies on them. Players see the game above the ground - trees, buildings, parks, terrain, water.

One of the early decisions that helped evolve this game very quickly was the distinction between the engine and renderer. Claude flagged this as an early trip-up that many attempts fail at, because although the UI is where the focus typically ends up, the engine is where the magic truly lies. The UI makes you feel welcome and provides a visual hook, but the engine and its features are the bricks and foundation, and the long-term hook for users. It's how their decisions and thoughts are realized over their city. The engine provides that distinguishing layer across the evolution of the game through different UIs. The engine took up less than a quarter of the commits, but provides a robust, tunable backend for the rendering engine. The switch from terminal, to 2D, to 3D rendering were commits that didn't change the contract. The city changed its mechanics over time: adding money (Claude suggested florins, which have a fascinating history), diffusion profiles, taxes, livability while the rendering changes were based on my subjective opinions and learnings on what is possible and useful.

Engine Revolutions

There are several good reasons to start with the engine, and let the renderer catch up. The engine is independently testable. It's the stable 'backend' that can be iterated on quickly with functionality without having to fix the issues in the UI. A simple, ASCII terminal representation of the game was a quick hack UI that bridged the visual gap to quickly see what was going wrong when we messed up a feature. To understand why, imagine trying to debug a golden file mismatch or search through a log file dump instead of saying "that second house didn't get built" in the terminal UI.

The city in the terminal client, as coloured ASCII. The terminal client The same city in the web client, rendered in 3D. The web client
Figure 1. One city, drawn twice. Day 3,700 — 476 homes, 474 jobs, 330 buildings, ƒ1,392 in the purse. Both clients run the same engine and the same build script; only the drawing differs.

The engine is heavily tested in isolation, and this makes it significantly easier to isolate bugs. In city builders, the bugs become more subtle as the basic mechanisms stabilize: they move into the model and coupling layers. Did we couple two models correctly, and what are the knobs that determine that coupling? How does pollution from a power plant influence the desirability in a place vs garbage/sewage. The answer is that the power plant has a specific diffusion profile, while garbage and sewage are city-wide constants, under certain models.

Same clock, two hands

I found that the engine evolved very quickly thanks to a few factors, besides the terminal UI. A document-test-code approach, along with a rigorous emphasis on exactly what goes into each version of the engine, helped to cleanly segregate the versions, while providing cohesive, playable, intuitive checkpoints. There wasn't a version of the engine that had the wrong mechanics, or a half-baked feature that didn't hook up to anything. Each version refined an existing feature or added a new one ground up with the necessary coupling. This allowed the engine to evolve through 5 milestones in 34 hours. Less than one-quarter of the commits were for the engine itself. The rest were trying to land the other half: rendering. Rendering changes were isolated to specific directories unless they found latent bugs that weren't caught without a player (me!) playing through. The UI took a lot more time to refine because it was highly underspecified - this was a combination of my learning curve here, the fact that it is hard to express the specific look you are looking for in words as precisely as components of the engine, and the fact that Claude was not able to catch many of the plainly evident bugs in the UI. Even now, the UI isn't where I want it to be, to showcase the engine modeling many of the underlying systems for a typical city. Once in a while, we actually caught engine bugs that had slipped through testing, diligently adding tests on the way. In fact, as part of writing this blog, we found a few that only showed up when we tried to compare the terminal UI with the web-rendered one.

Commit timeline: an engine track and a renderer track, 6 to 11 September.
Figure 2. How the two halves evolved, step by step. 129 commits in all; thirty of them touch the engine, which reaches E4 and stops there. Everything after is renderer. The dotted arcs are engine fixes the renderer showed the same evening.

There is a critical design choice to make when handling two systems like the renderer (driver) and the engine (server): pin the renderer to a specific version, or let both evolve while keeping the APIs stable across versions. We chose the latter since this forces stability on the API and allows for the fastest rate of development. If we break a basic mechanic (and we did this a few times: city should be viable with a decent rate of growth), then the renderer should expose it immediately. The renderer is the driver. It expects the APIs on the engine, and runs the event loop at a (tunable) cadence. It exposes the knobs for players to tune or for tweakers to change internal settings. This means that once we have stabilized the API for the gameplay, model additions and changes are automatic for the UI. An increased rate of growth when we fixed a pollution diffusion profile, or a lack of water when we fixed a water station bug (water stations got water when they weren't beside it) - were immediately visible on deployment.

Two-way Street

My goal with using Claude is to create something that wouldn't be possible even a couple of years ago - learn the systems and mechanisms, and build a complex game over a weekend. As with many of these adventures, I spent more than a weekend and learnt far more than I thought I would about this wonderful space. My approach was a top-down one, with a heavy emphasis on design, testing and strategy. I spent a couple of hours pondering over what I want to build, why I'm building it (nostalgia, token-maxxing, monetization or learning -> it's the last one) and therefore, what the end product should be. The next hour or so is spent on doing the literature surveys and understanding the typical techniques for building these games. This includes the frameworks, libraries, best practices, common pitfalls and novel ideas that I can include. In this specific case, this is where I decided to use Rust for the engine, decouple it from the renderer, rely on the barebones rendering in the initial versions, and ditch the ECS (Entity, Component, Systems) till a later version, since there was no need to take on that complexity. The renderer was also significantly simpler than I anticipated - using advanced rendering techniques did not yield the uplift I anticipated. This was a learning to see how UI is built, how you decide on a palette and how you refine the rendering over multiple versions.

The cadence of adhering to documenting the steps, adding tests and then implementing the feature to see the failures disappear helped with the basic set of issues. There were moments of tension where Claude had created coupling that wasn't discussed, or wasn't obvious. This caused it to go down increasingly complex paths to fix it, when the issue was clear from a designer's perspective. On the other hand, my emphasis for building on frameworks received significant (and valid) pushback regarding the complexity vs ROI tradeoffs. One particularly interesting detour that Claude took was to add taxes without thinking through their impact on viability. Claude argued for it without the necessary levers to consume the taxes, and the rewards and tradeoffs associated with them (crime, fire-risk, amenities). This needed to be pushed out to a later revision, not hacked into the current one.

Below the ground: pipes and numbers can be specified, coded and tested. Above the ground: roofs, lights and trees have to be seen and shaped over and over again.

In my experience with this build, I found that Claude worked well on some long-horizon tasks but when I did fall behind it in terms of current state of the game, I could see that the development was going off the rails. In other words, I couldn't let it build the game after creating (even highly specified) engine and renderer versions. It was a collaborative effort even with the engine modeling. The UI part of it comes down to taste and what I think makes it look good. I tend to be quite picky about each part of the UI, so this does mean I spend many hours tweaking it. The decoupling also helps here because the agent can be debugging an engine feature in a future version while I debate the UI elements with it in another session.

I learnt a lot through this experience. My upcoming posts will dive deeper into the engine level mechanics, how we worked through them and why the rendering took so many commits to reach this state. Stay tuned!

Corrections and arguments welcome — [email protected]

I work with AI to bring ideas to life. This blog is written by me. More

Rajeshwar builds and writes about distributed systems. He is a Staff SWE at Google working on Cloud Spanner.