**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>
A game gets a walkable character that can get into a car and drive it
in three lines and never mentions a camera:
let id = Character::new(...);
player_rigs.insert(PlayerId(0), PlayerRig::new(id));
Everything crafted is engine-side with defaults nothing has to specify:
coyote time, jump buffering, variable jump height, asymmetric accel and
decel, air control, landing recovery, a boom that snaps in and eases
out, look-ahead, speed pullback, and delayed recentring.
Script surface grows by three verbs (115 -> 118): game.player_character,
game.interactable, game.interact_prompt. Cars and doors-with-interiors
are derived affordances, so a generated game with a car and a house
declares nothing; game.interactable is only for chests and switches.
The prompt and the press share one search so they cannot disagree, and
it picks the nearest candidate in front rather than merely the nearest.
Four bugs found by looking at what ran, not by reading:
- Arcade never polled the gamepad at all. No game_input_states() call
existed anywhere in the app, so the pad's state never entered the
process and every binding downstream read a struct nobody filled.
- LT drove both brake and negative throttle, and brake force opposes
reverse motion. Measured: clean reverse covers 13.8m in 2s against
22.25m forward; with brake held, 0.63m. car.rs is unchanged -- a foot
on the brake winning is a car behaving like a car.
- Mount cleared `hidden`, which means "solid to everything, drawn by
nothing" -- so boarding left the driver as an invisible collider at
the kerb. Now uses attached_to, the sim's seat pin, and saves and
restores hidden rather than asserting a value.
- GameWorld::new() never set gravity; only reset_content() did. All 70
new() call sites floated, and four files had each independently grown
their own `world.gravity = 30.0`. A floating character never reports
on_floor, so the controller silently refused to jump.
The in-vehicle boom goes 9.0 -> 13.0. The boom is a time budget, not a
length: at the car's top speed 9m was 0.37s of road ahead, too little to
plan a turn. The test states it against CarConfig::top_speed, so raising
the car's speed fails the test instead of quietly making the view tight
again. The pivot deliberately does not rise with it -- eye.y is
pivot.y + sin(pitch)*boom, so the longer boom already buys the height,
and driving should sit lower and more planted than walking.
Player rigs now survive Blocks::clear(): where you are sitting and where
you are looking are the player's state, not the game's content, so a
script edit no longer ejects the driver mid-corner.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A game gets walking, jumping, a follow camera, mounting and dismounting without
ever touching a camera itself:
blocks.player_rigs.insert(PlayerId::LOCAL, PlayerRig::new(character_id));
blocks.tick_player_rigs(&mut world, &raw_inputs);
blocks.pre_step(&mut world);
rig.apply_camera(&mut world);
PlayerRig::tick fills BOTH the walking and driving fields of DriveInput every
tick, so changing seat needs no second code path — whichever block is listening
reads its own fields.
The character craft already existed in character.rs and passed; what was missing
was anything consuming it. The camera side is CameraConfig::on_foot (rotates
faster than it translates, so position lag reads as weight while aim stays
crisp; no recentring — the player aims it and it stays) and ::in_vehicle (lower,
35% speed pullback, recentring after a 0.9s delay so it yields to your hand and
only takes over once you let go). Mounting blends with smoothstep.
FOUR REAL BUGS, three of which no existing test could have caught:
- The camera blend never interpolated: `blend / blend.max(0.0001)` is always
1.0, so the rig held its old shape for the whole transition and snapped at the
end — precisely the cut the blend exists to prevent. It needed the blend's
ORIGINAL length, not the remainder
- The blend timer froze when its subject vanished, because tick bailed on the
entity lookup before advancing time. A car despawning mid-blend would have
frozen the camera permanently
- **heading_to_right returned LEFT** — the exact negation of forward x up. Its
doc said "+X" and its test asserted `r.x < -0.99 || r.x > 0.99`, which accepts
BOTH SIGNS and so could never fail. A test that cannot fail is worse than no
test, because it is counted as coverage
- The renderer and the sim use opposite yaw AND pitch signs. Derived by matching
the two eye-position expressions component-wise rather than guessing; the
conversion now lives in heading.rs as heading_to_camera_yaw/pitch — ONE named
boundary, never a negation at a call site. That discipline is why heading.rs
exists, and this is the same bug class that produced the reversed steering
Two changes from peer review: DriveInput.run is f32 so stick deflection gives a
real walk-to-run continuum instead of snapping at a threshold; and pre_step
gained a modality gate, because a player owning both a character and a car was
driving AND walking simultaneously — the stick steering your car was also
walking the body you left in the seat, invisible until you got out somewhere you
had never been.
108 tests across sim and blocks (from 88), including the full walk-in-drive-out
journey, analog deflection landing within 10% of the true midpoint, a reload
keeping you seated with the camera where it was, a vanished car putting you back
on your feet with working controls, and the affordance prompt agreeing with the
button at every distance on the approach.
Known gaps, reported not hidden: dismount picks a side but doesn't check the
GROUND there (spot_clear tests overlap, not floor), recentring uses velocity
heading so slow reversing can hunt, and PlayerRig assumes one local player
because world.cam_yaw is a single device field — split-screen needs a per-player
write path that doesn't exist yet.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Character feel went into character.rs rather than a fork — its own doc comment
argues a second controller would drift, and it was right. Acceleration and
deceleration ramps, deliberately ASYMMETRIC (starts have weight, stops are
crisp), partial air control, coyote time, jump buffering, variable jump height,
asymmetric gravity via the sim's own gravity_scale, landing damp.
New controller.rs: FollowCamera with separate position and rotation smoothing,
clamped pitch, a boom that snaps IN but eases OUT, look-ahead, speed pullback,
and delayed recentring that yields to the player's hand. Mount/Seat with camera
blending. movement_intent with deadzone and diagonal normalisation, so
diagonal WASD can't outrun cardinal.
ELEVEN FEEL TESTS, each named for the complaint it prevents: speed ramps
monotonically rather than stepping; stopping is crisper than starting; a late
jump off a ledge still registers; a jump pressed before touchdown fires on
landing; releasing early measurably lowers the apex; falling takes fewer ticks
than rising; air control is neither zero nor total; the camera cannot invert or
bury itself; dismounting puts you BESIDE the car, not inside it; the boom
recovers gradually rather than popping.
Two bugs it found in its own work, both invisible to endpoint-only tests:
- Variable-height jump broke an existing test: callers who set jump_pressed but
never hold `jump` — the older single-flag convention, and what a generated
game will most likely write — had their jump cut on the next tick. Now cutting
requires evidence the button is genuinely held; full height for everyone else
- THE CAMERA BLEND NEVER ADVANCED. `blend / blend.max(eps)` is always 1.0, so t
was pinned at 0: the rig held its old shape and then snapped — precisely the
cut the blend exists to prevent. Tracked against blend_total with smoothstep
Four of its own tests were wrong before the code was: air control limits the
RATE, so enough air time still reaches full speed (the real claim is that the
same input builds speed slower airborne); the buffer window genuinely cannot
survive a long fall; two needed the character settled on the ground first.
NOT DONE, deliberately: the controller is not yet exposed as verbs
(game.player_character({})) and arcade still uses its own camera and WASD path.
The prefab and its defaults exist and are tested; binding is the remaining step,
and stopping beat half-wiring input routing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
blocks/npc.rs + sim/sense.rs. Utility scoring re-run every ~0.45s per NPC,
staggered so a crowd doesn't re-plan on one tick. Candidates scored additively:
visit a POI (tag appeal x distance falloff x novelty x jitter), loiter near
someone (gated on sociability), go home (grows with time away), wander (the
fallback, so a world with no POIs still moves).
Three things do the legibility work. POIs, so NPCs walk to THINGS rather than
coordinates. Per-NPC seeded personality (haste/patience/sociability/curiosity/
homebody) so identical config still yields unlike villagers. And a day clock
with a per-NPC phase offset — benches read as afternoon, doors as evening —
which is what stops ten villagers doing the same thing in unison. Activities
are deliberately only four (Idle/Travel/Dwell/Follow); routines come from
sequencing them, not from twenty verbs.
Sensing reads THE SAME SOLID FILTER the mover sweep uses, so perception and
collision cannot disagree. obstacle_ahead sweeps the NPC's own box rather than
casting a ray, because a ray through a doorway reports "clear" for a body twice
its width. Blocked -> jump if the top is in reach with landing room, else
sidestep toward the side with clearance (blended with the goal so it curves
rather than turning 90 degrees), else a stuck timer abandons the goal.
Reading the existing tests caught a bug in the new logic: "low obstacle -> walk
over it" is wrong, because the 0.55 step-up is a TERRAIN contract and
sweep_axis blocks against static boxes at any height. That branch is gone —
it was exactly the perception/physics disagreement this module exists to avoid.
Two bugs the tests caught:
- MUTUAL SOCIAL LOCK: two sociable NPCs each chose to loiter near the other,
permanently. One moved exactly 0.0 units in 90 seconds. A social cooldown
stops Follow being re-picked immediately
- VILLAGE DRIFT: an unbiased random walk has no centre, and a trace showed a
villager 43 units out with every POI inside 18. Wander steps past a 26-unit
leash now aim home
Cost against a 16.6 ms budget: 50 NPCs 0.007 ms/tick, 200 NPCs 0.046 ms/tick
(full sim step — a pre_step-only figure would be a lie, since without
step_world the NPCs never move and re-decide more often).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The thesis proof: examples/gamemaker/resources/fixtures/racing.splash is a
complete playable racing game in 72 lines (12-corner oval from a waypoint
loop, 4 cars, gates, standings, restart) with no physics, no AI and no lap
bookkeeping in script. Blocks run engine-side at 60Hz: Blocks::pre_step
(intent -> motion) before step_world, post_step after; Blocks is Clone and
snapshots beside GameWorld so a failed eval rolls both back together.
- game.car (4 suspension raycasts on a box3d rigid chassis), game.character
(drives the existing mover sweep + owns idle/walk/run blending),
game.plane; game.drive/autodrive/speed
- Brains: game.wander/chase/patrol/caught — the fixture's hand-rolled AI,
absorbed engine-side
- Race kit: spawnpoint, checkpoint, place, race, standings, lap/rank/
finished, score/score_of (Shared-tier data, ready for replication)
raycast_vehicle audit (defects documented in car.rs, still live in xr):
libm sin/cos in steering (unreplicable), a wrong side-impulse denominator
(iaj.dot(iaj) where it should be (I^-1 aJ).aJ), and an unguarded division.
Kept the structure, replaced Bullet's friction solver with an arcade force
model: suspension acts at the contact point, grip and drive through the
centre of mass, steering as yaw torque — no lateral force can generate
roll, so it is stable by construction rather than by roll_influence fudge.
Engine bug fixed at the source: GameWorld::new() left rng at 0 and
xorshift64* is a fixed point at zero, so rand() returned 0 forever for any
world built through the sim API. reset_content seeds on every eval, which
hid it from gamemaker entirely; found by a wander brain that never left home.
Tape probe byte-identical; racing fixture evals clean and drives (AI follow
the line, gates bank in order).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>