- Rust 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| assets/models | ||
| crates | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| Megacity Architect - Complete GDD (v3).md | ||
| README.md | ||
Megacity Architect
A deep, modern city-building simulation written in Rust with Bevy. This is Phase 1 of the MVP roadmap from the GDD §15: prove the engine can do ECS rendering, node-to-node road placement, and deterministic pathfinding.
Running
cargo run --bin citybuilder
Controls
| Input | Action |
|---|---|
WASD / arrows |
Pan the camera |
| Mouse wheel | Zoom |
| Left click, then left click | Place a road between two tiles |
Right click / Escape |
Cancel the in-progress road |
F1 |
Toggle the pathfinding debug overlay |
G |
Spawn an agent |
F2 |
Capture a screenshot to screenshot.png |
1 / 2 / 3 |
Select the residential / commercial / industrial brush |
0 |
Select the de-zone brush |
| Left drag (zoning tool) | Paint the held brush |
Q |
Toggle the road tool on and off |
Escape |
Put every tool away, back to the launch state |
The build menu
Bottom-centre, and the only way to place anything on a road or on empty land. Three tabs share one row:
| Tab | Holds |
|---|---|
| Utilities | Power line, water pipe, power plant, water pump |
| Roads | Street, avenue, highway — priced per tile |
| Zoning | The four zone brushes |
A button arms what it names and a second click on the same button puts it
away, so there is no cancel button to hunt for. Utilities are priced — the
plant is 500 credits plus 2 per tick, the pump 750 plus 3, and a conduit is a
tenth of a credit a tile with the same tenth coming round as upkeep. Roads are
priced per tile and carry no upkeep, because a road only connects; upkeep
belongs to what a thing does. A price you cannot pay reads red with (short)
beside it rather than disappearing.
A tab is a view, not a tool. Flicking to the zoning tab to check a brush's lock state does not put away the plant you are holding, and picking a road brings the tab back to the roads row where that road lives. Only picking something arms anything.
Everything on the menu goes through one code path, World::build_building,
which is what charges the treasury. That is why the old P / Shift+P /
W / Shift+W hotkeys are gone: they wrote their tiles straight onto the
network, so a plant placed with a hotkey cost nothing while the same plant
from the menu cost 500. One route, one price.
The game launches with nothing armed. No tool is selected, so a click on
the map does nothing at all — the first thing a new player cannot accidentally
do is pave two tiles or drop a plant. Pick a road from the roads tab (or press
Q) to draw roads, a brush to zone, a catalog entry to build; Escape puts
everything away and returns to that state.
Placement respects the border order bounds → occupied → locked → money, and money is last because it is the only one of the four a player can change by playing. A conduit may cross zoning — GDD §4.2 attaches utilities to road networks, so feeding a built-up block must not require bulldozing it — but a plant may not touch anything, because a building that quietly ate a conduit would black out everything downstream.
Debug panel
Top-left, always visible. Shows the tick counter, the rendered and simulated
frame rates, the road tile count, the live and walking agent counts, and the
utility readout — the sim fps figure is the one that matters, since it
falls behind real fps when a tick costs more than its allotted time.
The utility readout shows zoned tiles and, per utility, how many are served
against how many the network can feed. A shortfall prints as a negative
suffix, so over-capacity reads as power 25 / 25 (-4).
Four buttons drive the population so the routing and rendering can be
stress-tested without editing SimConfig and restarting:
| Button | Effect |
|---|---|
+100 agents |
Raise the target population by 100 |
-100 agents |
Lower it by 100, culling agents until it is met |
clear agents |
Drop the target to zero, emptying the map |
stress: 5000 |
Jump to 5 000 agents with a fast spawn ramp |
find limit (10% drop) |
Ramp the population up until it drops, then settle there |
The population tracks the target in both directions: the simulation culls the oldest agents when the target drops, so the numbers in the panel describe what is actually on the map rather than only ever growing.
Zoning and the commute
The Zoning tab holds the brushes: Residential, Commercial,
Industrial and De-zone. Pick one and drag over land next to a road to
zone it; the tile tints teal (residential), amber (commercial) or rust
(industrial). The number keys 1 / 2 / 3 / 0 pick the same brushes
without opening the tab, because a brush chosen mid-drag is a gesture rather
than a decision. The button names its key in the caption, so the hotkey is
discoverable instead of folklore.
Once both sides exist the agents stop wandering and start commuting: a citizen on a residential tile walks to a commercial or industrial tile, then walks back. That is what turns population into traffic, which the gridlock and economy systems later in the plan read from.
A zone drawn beside a road rather than on one uses the neighbouring road as its entrance, because a destination that is not itself a road tile cannot be routed to. That detail is easy to miss: an agent receiving a non-road destination simply fails to route and stands still, which looks like an empty map rather than a broken one.
Power
Zoning alone does nothing until power reaches it. Pick Power pole from the build menu's Utilities tab and drag to lay a run; pick Power plant to plant one.
A pole is a brown pylon about the size of a steel column, one tile tall. It conducts, it does not generate — the plant is still the only source, and its 25-tile output is unchanged. What a pole adds is range.
Black wires are drawn between every pair of poles within 8 tiles, always. A wire is not evidence that power is flowing; it is a picture of what the network can do, the way a street map draws every junction whether or not anyone drove through it today.
Power is a flood fill from the plants through tiles that conduct, and the zone list is rebuilt whenever the network changes. So cutting a pole out strands everything it was feeding immediately, and running power to a previously dark zone brings it back without re-zoning anything. That keep-them-in-step property is the point: the alternative is a dark zone that agents still walk to, which reads as a pathfinding bug rather than a missing plant.
Poles are drawn as standing cylinders and wires as thin panels at pylon height, above both the road and zone layers, so a run of pylons stays visible over a street.
Conduits serve the neighbourhood around them
You do not have to run power or water to every tile. Every conduit serves the ground within 4 tiles of itself, so one run down a street powers and waters the whole block beside it, exactly as the street serves its houses without a driveway each — which is the gesture the cities games use and the one that keeps connecting a neighbourhood cheap.
- A house three tiles from a run of line, pipe or pylons is served with nothing built on its own tile.
- A tile past the end of the run is not: cover stops where the run stops, so a run that is too short or in the wrong place still visibly fails.
- Cover extends reach, it never adds supply. A conduit decides where utility is available; the source decides how much there is. Widening a run spreads power further but cannot mint more of it, so rationing at the source's cap still bites.
- A cut run loses its far half outright. Cover spreads from what is on the network, never from what is stranded behind a gap, so breaking a line disconnects the bank beyond it even where the tiles are close.
The cover is a rule of the network, not of the drawing: cover_tiles() on the
utility tile says how far each conduit spreads (4 for every conduit, 0 for a
source, so a plant alone serves only what touches it), and the flood fill
marks the ground around every live conduit it reaches. That is why it also
works for water for free — a pipe spreads the same 4 tiles, and needs no
change here to do it.
Water
Water is the same shape as power: Water pipe drags a line and Water pump places a source, both from the Utilities tab. A zone draws water from a live pipe it touches, exactly as it draws power from a cable.
Both networks run off one generic implementation in
crates/engine/src/utilities.rs. Power and water differ in their tile
enum and their supply-per-source constant, not in the flood fill, the
coverage bitmaps, or the rationing — writing the fill twice would mean two
copies of the interesting bugs, and the interesting bugs (a stranded
downstream bank, a coverage bitmap that went stale) all live in the fill.
Adding a third utility is one enum and one byte tag, not a second
implementation.
Water feeds more tiles per pump (40) than power per plant (25), because moving water is cheaper than generating electricity.
Utility overlays
A small panel sits on the right edge, halfway down — under the hand that reaches for it, and clear of the debug panel top left, the build menu bottom centre, and the demand bars top right (which stop a third of the way down). It has one toggle per overlay, each with a swatch in the colour that overlay is drawn in, so the legend and the ground always agree.
- Power washes the ground that can draw electricity in a warm yellow.
- Water washes the ground that can draw water in a cool blue.
Both start off, so the game launches with a plain map. Both are independent: you can look at water without power, which is the point of two switches rather than a hotkey.
The wash is translucent and inset by half a tile, so the grid and the roads stay readable through it — an overlay that hid the map would not be an overlay. It is drawn just above the conduits and below the agents, so a pylon under a sheet of coverage still stands out.
The overlay paints served ground, not the conduits a player laid: a run serves everything within 4 tiles of itself, so the painted shape is wider than the cable on the ground, and that wider shape is the answer to "why is this block still dark?" Cut a run and the tiles past the cut drop out of the wash immediately.
Supply
One plant feeds 25 tiles and one pump feeds 40, so a city can outgrow its network. When it does, the excess zones go dark: they stay zoned, but they are not homes or jobs, and they are painted dim on the map.
That is per-tile rather than per-network on purpose. A network-level "the city is over capacity" warning would leave a player counting tiles to work out which of their districts is stranded, and the honest failure is always specific tiles.
The ration is recomputed on every edit, not on a timer, because every read of the network has to see the same answer — including the render layer, which colours zones by service before the first tick has run.
Organic growth
Zoning is a request, not a promise. Painting a lot says "this is where I want housing"; the city answers by putting a house on it — but only where it can. A lot grows a building when all of these hold:
- it is zoned,
- it is served — power allocated and water allocated,
- it touches a road, so the house has a door,
- the whole 2×2 footprint is bare of roads, sources and other buildings, and
- demand still wants it.
The city grows one building per tick, so a district fills in over a few seconds rather than appearing at once — the pace a city grows at.
A conduit may sit under a house. Refusing growth over a pipe would make a street unbuildable, because pipes and lines cross blocks and every city would have to be bulldozed before it could be zoned; the building takes its connection from the ground it sits on.
A house that goes dark is not a house that goes away. Nothing here removes a building. A growth that loses its road, its power or its water stops being a destination — the router no longer sends agents to it and it stops paying tax — but it stays on the map and is drawn dim, in the same shape the zone mesh uses for an unserved lot. Cut a street and the block goes out and comes back, which is a legible answer about the network rather than a silent loss of what the player built.
Demand is zoned minus built: the bars read how much land is still empty,
not how many houses already answer it. The earlier jobs - homes shape made
any city with one house and no jobs permanently stuck at demand −1, so a city
that grew its first house could never grow a second.
homes and jobs hold road tiles, not building tiles: the destination a
router can actually use is the street outside the door. A house is a
destination because it stands, not because every tile under it was painted —
a 2×2 house on a one-tile lot is still a house, and a zone-driven scan would
have missed three quarters of it.
The 10,000 cap
Phase 2 tops the population out at 10,000 (PHASE2_POPULATION_CAP). It is
a release scope limit from the plan, not a performance one: the measured
saturation is 34,118 agents, so 10,000 is comfortably inside what the sim
already holds.
The cap bounds the population, never the request. The stress test and the
saturation ramp both drive target_agents far past it on purpose and must
keep working, so the cap is applied where agents are spawned rather than by
clamping the setting.
Finding the limit
find limit answers "how many agents can this machine hold?" without
babysitting the ±100 buttons. It works in three phases, and prints each level
to the console so the run is readable:
- Baseline — drop to 100 agents and measure how many ticks per second the machine actually sustains.
- Climb — raise the target by 25% (min 50, capped by the agent pool) every 100 ticks, holding each level long enough to be measured.
- Settle — stop at the first level whose rate falls below 90% of the baseline, and fall back to the last level that kept up.
The measurement is wall-clock, not simulated: the simulation runs on a fixed
20 Hz timestep, so its own tick counter reads a constant sim_hz regardless of
population, while what a heavy population really costs is longer ticks. That
makes the search a measurement of the machine, not of the simulation — it
never touches the RNG, so replaying the same seed still produces the same world
state. See crates/sim/src/ramp.rs for the state machine and its tests.
The log
Every run appends a transcript to saturation-ramp.log in the working
directory (crates/sim/src/log.rs). Runs stack up rather than overwrite, so
"is the machine consistent?" is answerable. A typical ending:
[ 126.9s] testing 34_118 agents (best so far 27_294)
[ 132.3s] testing 42_648 agents (best so far 34_118)
[ 138.0s] settled at 34_118 agents
[ 138.0s] === done: saturated at 34_118 agents, 34_118 agents held, 138.0s elapsed ===
A run that reaches the agent pool without ever slowing down says so — pooled out — rather than reporting the pool ceiling as if it had measured the
machine. Log failures are reported through the panel, never fatal.
What saturation actually looks like on this machine
Measured by walking the ramp headlessly, one tick per step at the 20 Hz cadence:
| Live agents | Mean tick | Worst tick |
|---|---|---|
| 100 | 34 µs | 2 ms |
| ~2 700 | 276 µs | 6 ms |
| ~7 200 | 772 µs | 14 ms |
| ~34 000 | 3.0 ms | 95 ms |
Cost is superlinear in population, and the 50 ms budget a 20 Hz step allows is crossed past ~34 000 agents, where a 95 ms tick means the simulation can no longer keep real time. That is the number this ramp exists to find.
Note this is not 10 000 000. The pool ceiling is set to 10 M to match the
GDD's target city, but the current storage and per-tick routing costs
(one route Vec per agent, a full HPA* query per idle agent, an O(n) len()
called once per spawn) mean the simulation reaches saturation near 34 000
before the pool is close to full. Getting to 10 M is an architectural change:
struct-of-arrays agents, a route arena, and batched routing — tracked
separately from this button.
Scale
One tile is 4 m, the Cities: Skylines tile, which is roughly a large
room. It is a reading of the simulation rather than a number the simulation
uses: every internal quantity is in tiles, and the constant
(citybuilder_engine::TILE_METRES) exists so the figures on screen can be
checked against the ground. Four is the tile a building fits inside: a 4×4
zonable block is 16 m square, a shop or an apartment building standing in a
real street, where a 16 m tile would have made every building a block and
every city a suburb of grain silos.
| Quantity | In tiles | In metres |
|---|---|---|
| One plant's power catchment | 25 tiles | 100 m |
| One pump's water catchment | 40 tiles | 160 m |
| One routing block | 4 × 4 tiles | 16 m |
| The 128-tile map, edge to edge | 128 tiles | 512 m |
| An agent walking | 0.35 tiles/s | 1.4 m/s |
Buildings span more than one tile
A building has a footprint, not just a price. A power plant is 3×3 (12 m square), a pump is 1×1, a road is 1×1, a conduit is 1×1. The footprint grows south-east from the tile you click, so your cursor is the northwest corner, and it is checked as a whole before anything is charged:
- one tile of the footprint in bounds → out of bounds
- any tile of it taken → occupied
- the rest of it is charged at the building's price, once
A 3×3 costs what the button says (500c, not 4 500c), occupies nine tiles, and shows its size in the caption and on the hover preview. Multi-tile buildings keep one entry in the buildings list, so upkeep bills them once rather than three times.
The power plant is a modelled building rather than drawn geometry:
assets/models/power_plant.glb, built in Blender at one Blender unit per tile
(3 × 3 × 2, with a chimney on the north-west corner of the roof) and centred on
its floor, so it loads straight onto its footprint with no offset.
The treasury
The city opens with 10,000,000 credits of starting capital. It is capital, not income: nothing is added to it on later ticks, so the balance only ever moves by what the city pays and earns.
Every served residential tile is a household paying tax_rate, and every
zoned tile costs upkeep_per_tile, whatever it is zoned for. At the
defaults (2/64 and 1/64 per tick) that is break-even at two tiles per
household: zone roughly twice as much ground as you fill in and the city
is exactly solvent, lazier than that and it loses money.
The treasury lines sit in the top-left debug panel, after the utility
readout: the balance, the net per tick signed — - for a deficit, + for a
surplus — and the next milestone while the ladder has rungs left.
Money is integer fixed-point, not f32. The balance is a sum of exact
products in an i64, so 1 000 ticks of the same city lands on exactly the
value the closed form gives it. A replay that changed only the treasury has
to hash differently or the determinism tests would pass on a run that had
diverged about money.
The tax base is households, not agents. Agents have no wealth of their own — they walk from a home tile to a job tile and are otherwise wanderers — and taxing them banks a surplus in a city that has been zoned but never energised. That bug was caught by a test that built an unserviced residential tile beside two hundred wanderers and checked the city was losing money: it earned over a thousand credits.
Demand (RCI)
The three bars beside the treasury say which way to build. A bar is green and grows right while the city wants that zone, red and grows left once it has more than it can use, and sits flat at the mark when there is nothing either way.
The bars are computed against served capacity, not zoned capacity. An unserviced residential block is not a home, and counting it as one would tell a player the city has more housing than it does — and then send them to build more of the zoning that is not working.
Bars are squared rather than linear, so a demand of two is a visible quarter of the track instead of a sixth: the range a player acts in is near zero, and pinning at the edge means a big number still reads as "a lot" without needing measurement.
Progression
Population milestones unlock zones. Residential is playable from the first tile; commercial unlocks at 100 households and industrial at 500, so the village → town → city sequence is enforced by the simulation rather than assumed of the player.
Nothing on the build menu is gated by a milestone. The plant, the pump and every road kind are open from the first tick, because a city that cannot water itself before reaching a hundred households is not a city the player gets to plan — it is a waiting room. A building may still be refused, but only for a reason the player can act on: land it cannot go on, or money it does not have.
A locked zone is refused, not painted and ignored. Painting it would leave a tile the map shows as zoned and the simulation treats as unzoned, which is worse than refusing: the player would see zoning succeed, watch nobody move in, and have nothing to point at.
There is no lump sum on a milestone. The reward is the unlock; a grant on top of it would make the milestone a payout rather than a milestone. An unlock is also a fact about what the player has achieved, not a live statistic — a city that shrinks back below the threshold keeps what it unlocked.
Architecture
Four crates, and one rule: the simulation never touches Bevy.
| Crate | Role | Depends on Bevy |
|---|---|---|
citybuilder_engine |
Tiles, blocks, heightmap, the road graph | No |
citybuilder_pathfinding |
Deterministic A*, HPA*, overlay data | No |
citybuilder_sim |
Fixed-timestep world, agents, seeded RNG | No |
citybuilder_render |
Meshes, camera, input, gizmos, the App |
Yes |
Keeping the lower three crates pure Rust means the simulation — its World,
roads, agents and routing — can be constructed, stepped and fingerprinted in
unit tests with no window, renderer or asset server. That is what the
determinism tests exploit.
Determinism
The simulation advances on a fixed 20 Hz timestep with a seeded splitmix64 RNG.
World::fingerprint() reduces the entire state (tick, roads, every agent) to a
single u64, and the tests use it to prove two worlds built from the same seed
stay bit-identical, and that stepping at 4 fps and at 60 fps agrees exactly.
Road model
Road nodes are tiles and edges connect adjacent tiles, so one grid is the
source of truth for terrain, utilities, zoning and traffic. Clicking two
aligned tiles creates a straight run of single-tile edges; clicking two
unaligned tiles creates an L-shaped detour through a corner. The road graph
uses append-only Option slots so a cached agent route can never alias a
different road after a removal.
Pathfinding
citybuilder_pathfinding::astar is a generic deterministic search: entries are
ordered by cost, then tiebreak, then tile, so equal-cost routes always resolve
the same way on any machine. tile_search wraps it into an exhaustive
reference A*; hpa implements hierarchical search over 8x8 clusters with
cached intra-cluster paths and inter-cluster boundary edges. World funnels
all routing through one private function so Phase 2 can swap in HPA* without
touching the agent loop.
Test status
citybuilder_engine 71 passed
citybuilder_pathfinding 28 passed
citybuilder_sim 165 passed
citybuilder_render 126 passed
Run everything with cargo test --workspace.