The game on top of the engine
The foundations post covered the substrate of Project Origin — pure C11, arenas, a data-oriented world, a two-backend GPU abstraction, and a deterministic lockstep sim driven by a command stream. None of that is a game. This post is about the layer that is: terrain, pathfinding, fog of war, UI, assets, levels — and the recent stretch of work that took it from “cubes slide around a 40x40 board” to something that moves, looks, and reads like an RTS.
The through-line for the whole layer is the same rule that governed the foundations: the sim must be bit-identical on every peer, allocation-free on the frame path, and small enough to hold in your head. Every system below had to earn its place against that rule, and a couple of the best bug stories are about what happens when a system quietly violates it.
Terrain: one rule for cliffs, shared by the mesh and the sim
Terrain (ADR 0012) is a flat tile grid — four contiguous u8 arrays per map: blocked, height
(each level is MAP_HEIGHT_STEP = 0.75 world units), material, and ramp (none, or sloping one
level toward +x/-x/+z/-z). Flat tiles at different heights form cliffs; ramp tiles connect
levels. No navmesh, no physics query — “can a unit stand here?” is an array read.
The part I care about is how passability is decided. The renderer expands tiles into a mesh by
computing four corner heights per tile (ramps raise the two corners on their rising side) and
dropping skirt walls where a tile’s edge sits above its neighbor’s. The sim computes the same
corner levels with map_corner_levels, and map_edge_match declares two adjacent tiles traversable
exactly when their two shared-edge corner levels are equal. Because that compares the very corner
levels the mesh renders, “the tiles connect at equal height” and “there is no cliff drawn between
them” are the same condition. Units cannot path through a rendered cliff or balk at a rendered ramp —
the sim and the mesh derive from one rule, in integers, with no float comparison anywhere in the
pass/block path.
Pathfinding: A* for squads, flow fields for armies
Moving one unit and moving three hundred are different problems (ADR 0013), so world_apply_move
routes by selection size, splitting at WORLD_FF_THRESHOLD (8).
Small selections get per-unit A*: 8-directional, octile heuristic, costs 10/14, no corner cutting
(the same step_ok predicate wraps map_edge_match), then a greedy line-of-sight smoothing pass so
squads hug corners instead of marching tile centers — except across height changes, where smoothing
refuses and ramps stay tile-by-tile. Large selections get one Dijkstra flood from the goal
(ff_build) into a shared flow field — a cost field plus a downhill direction per tile — and every
unit in the blob just samples its own tile with ff_sample. One flood serves the whole army; there
are WORLD_MAX_FIELDS (4) field slots recycled round-robin. Both routers share one static binary
min-heap, one closed set, and one direction table, so passability is defined once and there’s no
second pathfinder to keep correct.
That was the machinery. Making it feel like an RTS took three more passes of work.
Per-unit command queues. The single-order model became an order ring per unit: move, attack-move,
patrol, hold. Shift-right-click appends, P patrols (ping-pongs between two points instead of
popping), H holds. Queued orders are future sim state, so they’re folded into world_checksum —
which produced my favorite determinism bug of the project. The first version hashed each unit_order
with sizeof(unit_order), and the struct has padding after its u8 type field. Hashing undefined
padding bytes passed every test on macOS and diverged on Linux and Windows. The fix (commit
3624e7a) is to hash type/x/z/x2/z2 individually. Lockstep is a harsh teacher: the
checksum caught it, exactly as designed, but only once CI ran on a second platform.
Formations. A group move now assigns each unit an axis-aligned slot around the goal instead of
sending everyone to one tile. Small groups A* to their slots; large groups keep the shared flow field
to reach the goal region, and near the goal — where the field runs out — each unit heads to its own
slot, giving the fan-out for free. Slot layout is integer grid math, deliberately: no float sqrt on
the sim path, checksum-stable across runs.
Crowd settling. The ugliest movement bug: units ordered to a shared point orbited it forever.
The crowd fills the arrival radius, so trailing units never “arrive”; they seek at full speed into
the clump, the separation push shoves them back out, and the packed center buzzes every tick because
a symmetric velocity push has no fixed point. The fix (ADR 0023) is modeled on StarCraft 2’s soft
collision: arrive fades seek speed to zero inside ARRIVE_SLOW (2.5 u) of the final goal so
separation can cancel it; a unit near its goal that is blocked or cancelled for STUCK_TICKS (10)
settles via order_arrived — a new per-unit stuck counter, spawn-initialized, despawn-swapped, and
hashed into world_checksum; and idle separation became positional relaxation — resolve 25%
(SEP_RELAX) of remaining pair penetration per tick with a rest deadzone — a geometric decay that
provably converges, where the old velocity push overshot and reversed every tick. Groups now condense
and hold like SC2 instead of jittering. The only per-tick float function on the movement path is
sqrtf, which IEEE-754 requires to be correctly rounded, so it’s identical everywhere.
Fog of war: shadowcasting, then making it not flicker
Fog (ADR 0014) is recursive shadowcasting over the tile grid, run per player-unit position each
frame. It’s elevation-aware: a viewer’s sight level comes from its tile height, tile_blocks treats
higher terrain as sight-blocking, and mark_visible refuses to mark high tiles — so you see the base
of a cliff but not its top, and high ground genuinely sees farther. One computation feeds two
consumers: a bright array uploaded as an R8 texture the ground shader samples to dim terrain, and
a state array the CPU hard-gates on — a hidden enemy emits no instance, no health bar, no minimap
dot. No information leaks through the renderer because it never gets there.
Correct wasn’t the same as good. Two rounds of user feedback drove ADR 0022: “pitch black hides
terrain,” and “the light edges bounce between lit and unlit.” Unseen tiles rendered as a near-black
void, so the fix floors fog at 0.32 + 0.68 * visibility — fog dims, it never hides shape; height
relief and materials stay legible everywhere. The bounce was worse: the shader gated sun lighting on
fog visibility, so a tile on the vision boundary — where units shuffle a fraction of a tile every
tick — strobed between fully sunlit and flat ambient. Lighting is now computed independently of fog,
and two temporal smoothers stabilize the rest: a tile that loses line-of-sight holds FOG_VISIBLE
for a FOG_GRACE (8) update window before demoting, and brightness snaps up instantly on reveal but
fades down by FOG_FADE_STEP (26) per update. All of this is safe by construction, because fog is
render-only and excluded from world_checksum — two peers can hold different fade timers and stay in
perfect lockstep.
Light, shadows, water
For a long time the scene was flat-shaded cubes on flat-shaded tiles. The lighting stack went up in
deliberate steps. First a single directional sun with lambert shading — normals from screen-space
derivatives (dFdx/dFdy of world position), so no vertex format change, and the faceted look suits
the low-poly style. Then the engine’s first multi-pass frame: a depth-only render from the sun into a
2048² shadow map, with 3x3 PCF in the main pass — landed on Vulkan first, ported to Metal once the
look was confirmed, per the two-backend discipline from the foundations post. The shadow pass is one
extra instanced draw for the whole army; PCF scales with resolution, not unit count.
Water (ADR 0021) is the piece I’m happiest with, because every constraint showed up at once. Water
started as a flat blue material tile that blocked movement. Now it’s a wadeable depression:
map_walkable stopped rejecting it, and map_surface_y returns a constant -MAP_WATER_DEPTH (0.9)
so a wading unit’s feet drop to the bed — a sim-path change, but a pure branch returning a
compile-time constant, so checksums are untouched. The renderer sinks water tiles’ corners and the
existing skirt-wall logic carves the basin for free. The see-through surface forced a real renderer
decision: blending is order-dependent, so a translucent surface can only reveal a unit that’s already
in the color buffer. Water is therefore its own mesh (terrain_build_water, one quad per water tile)
in its own pipeline — alpha-blended, depth test on, depth write off — drawn strictly after
the unit pass on both backends. The shader animates it with summed wavelets and a Blinn sun glint,
clocked by gfx_set_time — wall-clock seconds, render-only, because nothing time-animated may ever
touch the deterministic sim.
UI: immediate mode, and the one-frame contract
The UI (ADR 0015) is a small immediate-mode layer: widgets declared fresh every frame between
ui_begin/ui_end, identity is an FNV-1a hash of the label, and only hot_id and active_id
persist. Everything draws through the gfx HUD pass the renderer already had — panels as solid rects,
text as atlas regions from an stb_truetype bake whose top eight rows are forced solid white so the
same texture carries the white pixel solid rects sample. One atlas, one draw path, nothing to port
per backend.
The load-bearing part is input arbitration. The mouse drives both world picking and UI, and a click
on a toolbar button must never also paint the tile beneath it — within the same frame, because the
sim steps deterministically each tick. So UI is declared first in the frame, ui_end() reports
mouse capture, and every world-picking site gates on it. That contract is a convention, not a
compiler guarantee, which is why it eventually got tests: a headless test_ui drives
ui_begin/panel/widget/ui_end with a synthetic input stream, plus a capture audit over every
world-interaction site. The test exists because a real regression shipped — active_id was cleared
in ui_begin instead of ui_end, and since a button detects its click on the release frame,
clearing the press early meant clicks never registered at all. The test now pins the exact sequence:
press over a button, release over it, must return clicked.
On top of that came a widget set v1 (ui_toggle, ui_segmented, ui_slider, tooltips) that rebuilt
the editor toolbar into segmented controls, and anchored layout (ADR 0020): panels declare one of
nine screen regions (UI_TL … UI_BR, ordered so col = a % 3, row = a / 3) plus a pixel inset,
resolved against the live window size every frame. The HUD — status top-left, perf readout top-right,
selection panel bottom-center — now survives a live window resize with zero per-site
screen_w - w - 8 arithmetic.
Assets and levels: boring on purpose
Two quieter systems round out the layer. Assets (ADR 0016) decode through vendored single-header
libraries — stb_image and cgltf, compiled in one non--Werror translation unit — into
caller-owned arenas, with a path-keyed cache on top. Each cached asset owns a private arena; a hot
reload decodes into a fresh arena and only destroys the old one after the decode succeeds, so a
mid-save or corrupt file keeps the last good data. The loaders never touch the GPU — callers upload
via gfx_set_* — which keeps asset loading headless-testable and the sim deterministic.
Levels (ADR 0017) are a versioned line-oriented text format: an ORIGIN_LEVEL <version> header,
a dimensions line, the four grids as character rows, then asset and spawn records. Version-gated
reads mean a version-1 file still loads today with height/material/ramp zeroed; text means a moved
spawn is a one-line git diff instead of a binary blob. LEVEL_VERSION is at 4 and climbing.
Runtime map size, and the bugs that hide in a stride
The original grid was a compile-time 40x40, and that constant had leaked into ~238 sites across 17
files. ADR 0019’s fix is the trick I’d reuse anywhere: keep every map-sized array at a constant
capacity stride — MAP_MAX (128) per side — and carry the active w, h as runtime fields, using
the top-left sub-rectangle. Because the stride stays a compile-time constant, the hot inline
indexers (fog lookups, influence, flow-field neighbor steps) need no map pointer; only bounds, loops,
and the world origin read the active size. A map stays a plain value that copies with memcpy,
which the editor’s undo ring depends on. map_init zeroes the whole capacity so unused tiles hash
deterministically, and the bench checksum came through the entire 238-site migration unchanged.
Then the stride bit back. The fog bright array now stores rows MAP_MAX apart, but the fog texture
upload still copied each row from bright[z * w] — the old packed layout. On any non-128-wide map,
every row after the first read from the wrong offset, scrambling the fog texture so units projected
no vision at all. The fix (commit 1351bc1) threads the source stride through gfx_update_fog into
both backends’ row copy — memcpy(&fog_cache[z * FOG_DIM], &bright[z * stride], w) — and bumps the
fog texture from 64² to 128² so max-size maps fit. A one-parameter bug, but the lesson generalizes:
when you decouple logical width from storage stride, every consumer that walks rows is a latent
bug until proven otherwise.
The renderer had four more of those latent assumptions, all silent truncations past ~64x64
(ADR 0024, commit 1d08478). A 128x128 map is ~130k vertices and ~195k indices — past both the old
mesh budgets and the u16 index ceiling, so terrain silently referenced wrapped vertices and drew
half a map; ground and water meshes now index with u32 on both backends. The shadow frustum was
hardcoded to the 40x40 board (ortho half-extent 26), so shadows just vanished partway out on bigger
maps; gfx_set_world_extent now feeds the map’s half-diagonal to the light’s ortho box. The minimap
scaled tiles by 1/w in x but dots by 1/h in y onto one square panel — rectangular maps stretched
and dots misaligned — replaced by one shared letterbox transform that tiles, dots, the camera box,
and click-to-pan all go through, downsampling to ≤64x64 cells so a big map can’t blow the 8k HUD-rect
budget. And resize itself (map_resize, exposed as per-axis 32–128 presets in the editor) is
centre-anchored like SC2’s Map Bounds: content shifts by half the size delta, and because the
world origin is the map centre, every kept tile keeps its exact world position — units, spawns, and
camera never drift across a resize. Verified live: a 128x128 checkerboard-height stress level at
120 fps on Metal.
A brain for the other side
The enemy needed to be less of a training dummy. Two pieces landed together: per-unit stances
(aggressive, defensive with a leash back to a post when a chase pulls past LEASH_RANGE, hold-fire),
with stance and post folded into world_checksum; and an influence map — a per-team presence
field on the tile grid, each unit stamping a distance-falloff footprint. The strategic AI compares
player vs. enemy influence around its home and either defends against the strongest invader or
attacks the player’s weakest-defended unit as a flank, then flows the idle army at the target. The
influence map is transient scratch recomputed each AI tick, so it stays out of the checksum — but the
orders it produces go through the same command stream as everything else, so the resulting sim is
still bit-identical across runs.
Receipts
The sim is benchmarked headlessly (--bench N spawns two armies, orders them through each other,
ticks 600 times) on an Apple M4, clang -O2:
| Units | ms/tick | % of a 16.67 ms frame |
|---|---|---|
| 456 | 0.159 | 1.0 % |
| 944 | 0.478 | 2.9 % |
| 1852 | 1.208 | 7.2 % |
| 2768 | 2.030 | 12.2 % |
At 2768 units — the dense-pack ceiling of the built-in level, past the 2000-unit target — the deterministic sim costs 12% of a 60 Hz frame, leaving ~14 ms for rendering, and still runs 8x faster than real time headless, which is what replay verification and a future dedicated server actually cash in. Same seed, same commands, identical final checksum, every run.
Where this leaves the project
Six months ago this was an entity array and a swapchain. Now: cliffs and ramps with one passability rule, two pathfinders sharing one heap, command queues and formations and SC2-style crowd settling, elevation-aware fog that neither hides the map nor strobes, sun shadows on two GPU backends, water you can wade into and see through, an immediate-mode UI with a tested input-capture contract, and maps that resize at runtime up to 128x128 without moving a single tile out from under a unit.
The pattern I keep noticing: almost every hard bug in this stretch — the padding bytes in the order
checksum, the fog row stride, the u16 index wrap — was a mismatch between a layout the code
assumed and a layout the data had. The determinism machinery from the foundations post is what
made them findable. The checksum doesn’t just guard multiplayer; it’s the tripwire that turns “weird
on Linux” into a one-commit fix.