The world seen from above
A side-scroller's world is a heightfield and a parallax stack. A top-down world is a plan: a grid of cells, most of them solid, with the playable space cut through them. Almost everything below follows from that one difference, and most of it was learned twice before it was written down.
The engine parts are engine/25_grid.src (grid, flood routing, sight lines,
file format) and engine/65_bake.src (the full-circle sprite bake).
Rule 1 — generate the plan OFFLINE, into a file
The first version of our city was a function: which cell you stood on was arithmetic on a fixed block grid. That is elegant, costs nothing to store, and produces a town where every block is the same square and every junction is the same junction — a street plan a player has seen in full after ten seconds.
Generate it once, write it out, load it back:
./bin/game gen --seed 7 --out city.jsonl # a separate program
./bin/game --city city.jsonl # play it
The payoff is not file size, it is that the plan becomes data you can look
at: diffed between seeds, hand-edited, regenerated, replaced wholesale, and
re-skinned without touching the code that draws it. grid_encode/grid_decode
do it as one header line and one run-length row per line — 168² cells is 14 kB.
Keep a fallback that generates at startup when handed no file, or the game has
a bootstrapping problem the first time someone clones it.
Rule 2 — irregular means SUBDIVISION, not jitter
Do not build a grid and then perturb it; cut the map up instead. Take the whole rectangle, put a street across it somewhere that is not the middle, and do the same to both halves until the pieces are block-sized.
Because a cut only ever spans the piece it divides, streets do not line up across the map. That single property is what gives you T-junctions, streets that run for three blocks and stop, and a block twice the size of its neighbour — none of which you have to ask for.
Then dress each leaf: a pavement ring, buildings in lots divided by a one-cell alley, a notch out of a corner, and every so often a park or a yard instead of buildings. Two dozen lines, and it is the difference between a plan and a grid.
Assert the irregularity, or it will quietly regress to a grid: walk several scanlines, collect the gaps between streets, and require more than a couple of distinct values.
Rule 3 — one integer per cell, and passability is a BITMASK
Store the cell's kind and its decoration in one integer — TL_BLDG + tone
rather than two parallel arrays. Then the engine's Grid is a view over the
same array rather than a copy, and the file format falls out for free.
Order the values so that everything from the first solid kind upward is solid. Passability is then a bitmask over cell values, and one map serves every kind of mover without a second copy:
MASK_DRIVE = 1 // roads only
MASK_WALK = 7 // roads, pavements, parks
The trap this creates, which you will hit: once a cell carries a tone,
cell == TL_BLDG() matches only tone zero. One place in our generator still
compared a raw cell to a kind and five sixths of the kerbs silently stopped
being generated — the pavement count fell 4% and nothing else complained. Ask
for the kind through an accessor everywhere, and assert both halves: that
buildings do carry tones, and that a cell's kind never leaks one.
Rule 4 — routing is a FLOOD, not a heuristic
Anything that has to get somewhere floods outward from the destination, breadth-first, once; every reachable cell gets its distance, and everything heading there reads the cell under it and walks downhill.
f := flow_build(city_grid(c), tx, ty, MASK_DRIVE())
ax, ay, ok := flow_aim(f, city_grid(c), x, y, TILE(), 5) // 5 cells ahead
We wrote the other thing twice — travel along the street you are on until you
are level with the target, then turn. On a regular grid it is optimal and
free. On an irregular plan it is a guess, and it fails by pressing into a dead
end it cannot see round, forever. It also fails invisibly: a stopped
vehicle looks like a stopped vehicle, and two rounds of screenshots found
nothing. One --trace line a second found it immediately.
One flood is about a frame of drawing and is shared by everything with the same destination — which is what makes it affordable for a crowd. Rebuild only when the destination changes cell; police chasing a player need one rebuild per cell the player crosses, not per tick.
Aim several cells downhill rather than at the next one, or the mover twitches
instead of steering. Off the network entirely (shunted onto a kerb),
flow_aim heads for the nearest cell that is on it — without that, anything
pushed off the road is stuck for good.
Rule 5 — traffic holds a lane it has to MEASURE
With irregular streets there is no block size to take a modulo of, so a lane centre cannot be computed — it has to be found. Scan out along one axis for the nearest road cell, widen to the whole contiguous run, take its middle, and offset to one side so oncoming traffic passes instead of colliding.
city_road_x originally returned the block's own carriageway. On the far
pavement the nearest road is the next block's, so everything self-driving
was steered through the building between them. Nearest, always — and assert it:
the middle of a street must itself be a street.
The same measurement tells you which way a street runs (compare the run length in x against y), which is what a mover needs to decide whether it may travel along the road it is standing on.
Rule 6 — a top-down vehicle goes where it POINTS
Three numbers, and all three were wrong in our first cut.
Grip. Velocity leans toward the nose at a rate, rather than snapping to it:
v += (fwd·(v·fwd) - v) · clamp(GRIP·dt). Snap it and the car is on rails —
no drift, no handbrake turn, every corner identical. Omit it and it is a
spaceship. This one blend is most of what "feels like driving" means.
Steering must fade at BOTH ends. It has to build from a standstill, or a parked car pirouettes and is a tank; and it has to fall away with speed, or the fast vehicles are unsteerable at exactly the speed you most want to steer them.
Drag is for coasting only. A linear drag term makes the real top speed
power/drag, approached exponentially — see ressort-side-scroll-world, where
the same trap cost a saloon two thirds of its rated speed.
Braking acts against travel, not against the nose, or braking mid-slide accelerates you sideways. Move one axis at a time against the map so a glancing hit slides along a wall instead of stopping dead.
Rule 7 — the crowd needs separating, and the pedestrians need a cooldown
Once the streets are busy enough to look alive, two things start looking broken.
Vehicles interpenetrate. A circle test is enough — a box test on a rotated sprite wants the separating-axis theorem and nobody can tell at this size. Push apart by mass ratio so the bus shoves and the sports car gets shoved, and scale the damage of a shunt well below a wall impact: at full rate a truck clipping you at 30 m/s removes half a car, and every mission vehicle dies in traffic.
Pedestrians spin. Ours turned 90° on every blocked tick — 5400°/s, which reads as a figure drilling into the pavement. Turn at most a few times a second, toward a direction that is actually free (try left, then right, then back), and stop re-aiming a fleeing one when the player is standing on top of it: "away from you" flips every tick at zero distance, and a figure that reverses at 60 Hz reads as two figures.
Rule 8 — density is per YARD, not per magic number
Population as a fraction of the map: cars = len(road_cells)/42,
people = len(pavement_cells)/13. A city twice the size then needs no
retuning, and a screen shows the same number of things wherever you are. Ours
was a constant, and tripling the map turned a busy street into an empty one
with no code change to blame.
What to assert without a window
- every open cell can reach every other (an unreachable street is an unfinishable objective)
- following the gradient from the far corner arrives
- an aim point is on the network, and far enough ahead to steer at
- the middle of a street is a street
- no two cells of the same block have the same size gap on every scanline
- spawns land on ground the thing spawned can actually occupy
- a written plan reads back cell for cell
- a blocked pedestrian turns a handful of times a second, not sixty
Then render it and look, because numbers can be right while the render is wrong. The lane markings were absent for a week: the code asked whether a cell's centre was the centre line, which is never true on a road of even width.