Digicity

What inspired it

I wanted a city builder with a retro feel that incorporated modern city building challenges that was fun, but also not overly complex. More importantly, I didn't want micro-transactions, time delays, or other attention and money gobbling tactics to contend with. I couldn't find something that worked well on iOS, so now we have one.

The genre's original bargain was that you zone land and the city decides. You draw a rectangle and say "this is residential" — and then nothing happens, because there is no road, or no power, or nobody wants to live next to the coal plant you put upwind. The city is an argument you are having with a simulation, and you lose it constantly.

Almost every city builder on a phone has inverted that. You tap a plot, a building appears, a timer runs, and you pay to skip the timer. The simulation is gone; what's left is a clicker wearing its clothes.

So the whole project is one stubborn rule: nothing appears because you placed it. You get roads, power lines, water pipes, nine kinds of zone, and a clock that starts in

  1. Everything else is the map's decision.

How I built it

One-way data path. There is exactly one WorldState in the process and exactly one thing allowed to touch it:

PlayerIntent ──▶ SimulationActor ──▶ WorldSnapshot ──▶ SnapshotRelay ──▶ TileRenderer
                 (owns WorldState)    (immutable)       (@MainActor)      (Metal)

Everything outside the actor submits intents and reads immutable snapshots. That buys determinism as a structural property rather than a discipline:

$$f(\text{seed},\; [i_0, i_1, \dots, i_n]) \longrightarrow W$$

The tick is 18 passes in a fixed order. Connectivity, power and water capacity, commute, freight, three kinds of pollution, noise, land value, coverage, fire, demand, the anchor, zone evaluation, the budget, aging, contamination, eras. One call is always exactly one tick; the speed control changes the rate, never the multiplier, or the simulation stops being reproducible.

Six of those passes are too expensive to run whole. They are sliced — each tick processes one eighth of the map, and every sliced pass takes the same eighth so they stay in step:

$$128 \times 128 = 16{,}384 \text{ tiles} \qquad \frac{16{,}384}{8} = 2{,}048 \text{ tiles per tick}$$

Eight ticks is one full sweep. At the fastest speed that's one complete sweep of the city every second.

Rendering is Metal, batched per tile rather than per layer. A tile's ground, road, pipes, buildings and power lines are drawn together. The obvious alternative — one pass per layer over the whole map — paints one tile's soil over its neighbour's grass and puts the entire city on a plinth. I learned that by building it that way first.

The art is baked, not drawn. 327 sprites across six 4096² atlas pages, produced by a Blender pipeline of ~35 Python scripts that models the geometry, bakes the lighting variants, and packs the pages — with its own pytest suite, because an art pipeline that silently ships the previous atlas is worse than one that crashes. The next version 26.10 will have more art verity.

RevenueCat handles the one thing the game sells. import RevenueCat appears in exactly one file in the repository, behind a module that has no opinion about the store — it hands back an entitlement decision and nothing else. That boundary meant I could unit-test the entire purchase-gated surface of the game without a network or a sandbox account.

What I learned

Determinism is a product feature wearing an architecture costume. I built the actor boundary for concurrency safety. What it actually gave me was a game that can be saved, replayed, and tested exhaustively — and a save format that is a genuine trust boundary, because decode is the only place bytes that did not come from a running simulation become a world.

Green tests lie in more ways than I knew. A filter that matches no tests exits 0 and prints a passing run. A suite that skips itself on the wrong device reports skips as passes. A build against a stale generated project tests code you deleted. I now pin the expected test count in every run report, because "it passed" is not a measurement.

Measure, then claim. Every performance number in this codebase is one a run actually produced. The comments say why a rule exists and what breaks without it, never what I assumed was true.

The challenges

The atlas did not fit. As bgra8Unorm_srgb the texture array cost 480 MiB resident — enough to get the app killed on the devices I care about. Moving to ASTC 4x4 brought it to 128 MiB. The compressed texture is derived rather than committed, because 128 MB of raw blocks is something git cannot delta-compress and every re-bake would add another 128 MB to history forever.

Land value was the slowest thing in the game. Replacing the naive computation with a distance transform measured 4.55x faster, pinned by an equivalence test against a transcribed reference implementation so the optimization cannot quietly change what the city does.

Going universal broke tests by passing them. Digicity was iPad-only until recently. Adding landscape iPhone taught me that a Pro Max in landscape reports a regular horizontal size class — exactly like an iPad — so every compact adaptation had to gate on vertical size class instead. Worse, the new compact test suite skipped itself on an iPad destination, which meant a full iPad test run reported seven skips as seven passes. Now a pre-tool hook refuses that destination outright.

And the business model was a design problem, not a pricing one. In the initial design, the whole game is free: every tool, the entire map, no ads, no timers, nothing held back. What the free game cannot do is save. That isn't a wall dropped in front of a feature — it's the one thing a sandbox city builder is actually worth money for, and it's a single purchase, not a subscription. In the second version I added the ability to have era's that have historically accurate challenges.

Built With

Share this project:

Updates

posted an update —

26.10.2 is progressing with…. Ships! We’re also adding rivers and bridges— all with original artwork. We’re also working on custom rules for your game so you can pick and choose what conditions you want to play against.

Log in or sign up for Devpost to join the conversation.

Submission history