**The flat plane is gone.** Terrain existed all along — heightfield collision
via box3d, AO and shadow raymarching against it, a mesh path, a
`game.terrain` verb — and arcade simply spawned a flat slab instead. That is
the fourth capability this week that was built and never called.
Turning it on was not enough, because the generator had faults that a
screenshot explains faster than prose:
- **Single-octave value noise.** Detail at exactly one scale reads as melted
blobs. Now fBm with domain warp, which is the single highest-value knob
here: warping the sample point bends contours into ridges and valleys
instead of round lumps on a visible grid.
- **`step` defaulted to 1.0**, quantising every smooth slope into 1-unit
stairs. The old default renders as a literal contour map. Off by default.
- **Noise was indexed by CELL INDEX, not world position**, so asking for a
finer mesh silently generated a different landscape. Resolution should buy
detail, never a new world.
- **Colour came from height alone**, which paints terrain in horizontal
stripes like a contour map. Now height AND slope, so rock lands on cliff
faces and grass on the shelf above them.
Generation moved to `libs/game/gen/terrain.rs` as a pure function. Beyond
testability that was forced: arcade's demo world has no script VM, so the
only generator in the tree was one it could not reach.
`rim_relief` is the load-bearing idea. Terrain interesting everywhere is
terrain you cannot put a town on; terrain flat enough to build on is a green
table. Growing the relief outward gives a playable basin ringed by something
worth looking at, and doubles as a soft boundary. It SCALES the noise rather
than adding a radial ramp — the ramp version has no noise in it and renders
as a smooth machined ring between two flat plains, which I built first and
threw away after looking at it.
Two things the tests taught me rather than confirmed:
- Normalising fBm by the sum of octave amplitudes — the textbook form — makes
five octaves come out FLATTER than one, because summing decorrelated fields
concentrates them about the mean. Normalising against the field's own
extents makes `amp` mean literal relief at any octave count.
- The octave test measures CURVATURE, not slope. At gain 0.5 / lacunarity 2
every octave contributes equally to slope — that is what self-similar
means — so a slope-based test reports no difference while the terrain
visibly gains detail. My first version of that test was wrong, not the code.
Statics get one conform pass after composition; movers already clamp to the
terrain every tick in `step.rs`, so the player, villagers and car find the
ground themselves.
**Cars now touch the road.** Kenney authors vehicles origin-at-the-contact-
patch — tyres exactly on y=0, with each wheel node lifted by its own radius —
and every vehicle in every kit measures min.y == 0, verified across all 4442
GLBs in the library. We were dropping the model by `half.y` instead, a rule
that is right for a walker (a Mover's box bottom really is its feet) and
wrong for a raycast vehicle, whose suspension probes from the chassis origin.
The float was suspension travel plus wheel radius, 0.341 units, predicted in
closed form and matched by simulation to 1e-5. The same line also shifted in
world Y after rotation, so the mesh slid out from under a leaning chassis.
Both now derive from measured bounds along the body's own down axis — no
constant anywhere. Note the convention is real but NOT universal: track and
road pieces go to min.y = -1.0, so it must be read, never assumed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
INTERIORS ARE POCKETS, NOT IN-PLACE ROOMS — a generated room lives at its own
origin elsewhere in the same GameWorld, and a door is a portal to it.
Rejected building the room under the house shell for three reasons. The roof is
the blocker: a third-person camera outside sees the shell, so a character who
walks in vanishes under it, and fixing that needs roof cutaway or per-object
culling — a real renderer feature, not a detail. Kenney footprints are only a
few units across, so an in-place room is whatever the walls leave over, while a
pocket can be bigger inside than out. And a pocket introduces NO NEW CONCEPTS:
it is coordinates in the same world, so host-authoritative replication,
determinism and eval rollback are unchanged, and two players in two different
houses are just two players standing far apart. That last was a hard
requirement and it falls out for free.
DOOR ALIGNMENT is a parameter, deliberately: libs/game/gen must not depend on
libs/game/render, or layout generation would require a GPU. door_side_from_
colliders() takes the boxes as plain data, walks each edge just inside the
footprint, and picks the side with the longest run no box covers — that is the
doorway. Inside the generator the door cell is FORCED via a role-filtered fit,
because a door and a wall segment carry the same connection mask, so an
unrestricted fit sprinkles doors randomly along a room's wall ring. Kit::fit
now delegates to fit_where(target, allow, rng) so rotation arithmetic stays in
one place with one set of tests on it.
Two NPC defects surfaced by testing indoors, both real:
- A door that LEADS somewhere scored the same as a decorative one, and since
the wander fallback sits near 0.5, a lone doorway only tempted homebody
personalities — 4 of 24 seeds. Doors with `leads_to` now score 2.2x: 9 of 24
for a single door in an empty field, and a real village has one per house
- FOLLOW'S SCORE PEAKS AT DISTANCE ZERO while its steering parks at 2.2 units,
so an NPC already standing beside someone picks "go stand beside them" and
then does nothing for up to eight seconds. Outdoors that is invisible. In a
room, where everyone is permanently within 2.2, FOUR NPCS FROZE SOLID for the
entire run. Follow is now only considered when the target is worth walking to.
This would have shipped as "NPCs stand still indoors"
Blocks never reposition an entity: Npc::tick emits DoorUse{entity, poi, to,
entering} and the host performs the write — the same queue-and-drain shape the
audio emitter uses. Coming back out is unconditional, so an NPC can never be
lost behind a door; while inside, decide() short-circuits to a local wander,
because every POI, friend and home is outside and scoring them would aim the
NPC at an interior wall.
Release cost per interior: 4x4 room 20 us / 41 tiles, 12x10 407 us / 186 tiles.
Twenty houses is well under a millisecond. Always exactly two layers — shell
and furniture — so one kit is one batch, as everywhere else in levelgen.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Procedural LAYOUT + authored TILES: the AI decides where things go, Kenney's
artwork decides how it looks. Beats both random prop scatter and purely
procedural geometry, and it is how low-poly games are actually made.
ADJACENCY HOLDS BY CONSTRUCTION, not by rules. Rather than pairwise rules
between named roles (fragile and quadratic), layout marks occupied cells, each
cell reads its target mask off its occupied NEIGHBOURS, and a tile is chosen
matching that mask at some rotation. Both sides of every shared edge derive
from the same grid, so only rotation arithmetic can be wrong — and that is
what the tests pin. Junction type is never specified by a caller: two crossing
paths yield a crossroad, one teeing in yields a T, purely from neighbour count.
The interface deliberately keys on a 4-bit N/E/S/W `mask`, not on `role`, so
these algorithms don't depend on the asset index's filename taxonomy — if a
kit classifies `road-split` oddly, setting the mask keeps everything working.
Incomplete kits fall back to a superset tile: a crossroad standing in for a
missing tee leaves a stub opening onto nothing, which reads as unfinished road
rather than a hole in the world.
Generators: road_network (polylines), road_from_spline (the authored-tile
counterpart to the existing ribbon mesh — a kart track wants the ribbon, a city
street wants tiles), town (street grid, buildings on lots that front and face
a street, props at junctions), dungeon (BSP rooms + corridors, connectivity
guaranteed by the spanning tree and PROVED by flood fill over 12 seeds), plus
place_tile as the escape hatch.
track from closed spline 13 us 120 tiles
road network (13 paths) 32 us 397
town 24x24 71 us 547
town 60x60 822 us 2710
dungeon 48x48 137 us 1180
dungeon 96x96 932 us 3616
Town road histogram: 1248 straight, 121 cross, 44 tee, 4 corner, 0 dead ends —
correct for a closed grid. Zero mismatched edges on both large levels.
Two bugs caught by its own tests: indexing one kit with another kit's
placement indices (now impossible — layers merge by kit id, invariant
documented), and a superset-fallback that allocated a Vec per cell and tripled
generation time. The allocation-free count-then-pick rewrite is faster than
before the fallback existed: dungeon 96x96 went 1952 us -> 932 us.
Seed-deterministic via GenRng, never the world rng, so a town replicates as
(kit, seed, params). Not done: walls/doors around dungeon rooms (floor-only
today), multi-cell buildings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Seed-deterministic, baked at spawn, emitted straight into the 24-byte packed
vertex format. Two devices given the same seed produce byte-identical
geometry — which is what lets a forest replicate as (preset, seed, position)
tuples instead of mesh data.
- L-system plants: expansion + 3D turtle + skeleton, 8 species (oak, pine,
palm, bush, fern, cactus, dead, grass)
- Surface nets (not marching cubes — fewer, better-shaped triangles at
low-poly) for rocks, boulders, mushrooms, clouds, blobs
- Spline tracks with width, banking, curbs and rails, returning centreline
frames that carry lap distance — so spawn points and checkpoints derive
from the track instead of a second hand-written list
- Poisson-disk scatter with flatness/height rules
- Texture generator with CPU mip chains (backends never generate them)
- LRU cache keyed by FNV-1a over the full recipe with floats hashed by exact
bits; -0.0 and 0.0 normalise together (identical geometry), 4.0 and
4.000001 stay distinct. Meshes hand out as Rc so eviction cannot pull
geometry out from under a frame mid-draw
- DrawGameFoliage: growth and wind as an OPT-IN shader variant, a sibling of
DrawGameSkinned rather than a flag inside the shared shaders — wind costs
~20 vertex ALU and the cube shader draws most of the world. Growth and flex
weights share one packed nibble pair, so both animations cost zero extra
vertex bytes
A realistic forest — 150x150 m, 582 trees, 3 species, 6 seeds — generates in
1.70 ms with 470 KB resident: 6 generations and 576 cache hits.
Three bugs the unit tests had passed, found by writing an ASCII silhouette
probe because captures were out of scope: every species came out ~4x its
requested height (the test compared two sizes RELATIVELY, so a uniform
overshoot sailed through — now the finished skeleton is measured and rescaled,
and the test asserts absolute height for all 8 species at three sizes); palm
emitted no foliage at all because its L-system contained no leaf symbol; and
cactus sprawled sideways like a shrub. Pine also dropped from 4 iterations to
3 — 15032 -> 2504 triangles, 728 -> 68 us — because 15k triangles for one
background tree is indefensible.
Honest caveat: that is a silhouette judgement, not a rendered one. Shading,
leaf-card orientation and the wind/growth animation are visually unverified.
Script verbs are not wired yet — the crate is a library only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>