**The ramp was a solid cube.** Reported as "i dont seem to be able to walk
the character up the slanted block towards the platform" — and the wedge
had no collision handling anywhere. Movers sweep against static AABBs, so a
`Shape::Wedge` was collided as the box that CONTAINS it: the ramp built to
be walked and driven up presented a vertical wall at its low edge, and you
stopped dead against nothing you could see.
Wedges are now surfaces rather than walls, handled exactly the way terrain
already was — the symmetry is the point, since terrain had solved this
problem years earlier in the same file. They are excluded from the axis
sweeps, and a ramp floor pass sits underneath: walk up where the slope
rises less than CLIMB, blocked where it rises faster. That falls out
correctly at both ends without special-casing either — the gentle slope is
walkable, and the wedge's full-height back face is still a wall, because
there the surface jumps well past CLIMB in one step.
Sampled across the mover's whole footprint, not just its centre, so
standing with half your feet on a ramp stands you on the ramp.
**Conforming statics to terrain now ADDS the ground height instead of
replacing it.** Replacing looks equivalent, because everything is authored
resting on flat ground — right up until something is deliberately in the
air, at which point it flattens every platform, buried base and raised
ledge onto the dirt, and the failure reads as the level's fault rather than
the function's. Adding is a no-op on flat ground and rides the slope
elsewhere. Found by adding a jump course whose heights it ate.
**A crossroads and a side street.** One straight road reads as a corridor;
a junction is the smallest thing that makes a place feel like it has
somewhere else to be. The side street runs out to the yard, so the physics
corner is somewhere you drive TO rather than somewhere that is merely
nearby.
**A jump course**: a static step to read the route from, a platform that
slides across your path, one that rises and falls, and a wide still ledge
that is obviously the end. Gaps are sized against the controller's actual
jump distance rather than eyeballed, and the two movers run on different
periods so they drift in and out of phase instead of presenting the same
crossing every lap. The ramp's high edge is the run-up, which is what turns
two separate toys into one thing to do.
Three tests, one per claim: a character walks up, the back face still
stops them, and standing on the slope reports on_floor (without which the
controller silently refuses to jump). The first version of the walking test
passed for the wrong reason — its world had no ground, so the walker fell
past the ramp and met it from BELOW, where being blocked is correct.
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>
AO — ZERO EXTRA BYTES. It lives in the alpha byte of the packed colour lane,
which was already dead weight: model.rs wrote the glTF baseColorFactor alpha
there, the skinned shader multiplied it into v_tint.w, and the pixel shader
threw it away by returning a hardcoded 1.0. We were paying for the channel and
never reading it. 24 bytes/vertex before and after.
The multiply scales AMBIENT ONLY — `albedo * (ambient * ao + direct)`. Folding
AO into direct as well would darken a sunlit wall twice, and direct light is
already zero where a surface faces away, which is precisely where occlusion is
the ambient term's job.
Static-only holds BY CONSTRUCTION without touching skin.rs: that file writes
pack_unorm8x4(1,1,1,1), so characters get ambient * 1.0 — an exact no-op
through the shared shader.
Cost over the real catalogue (4,442 models, 2.5M verts): 2.64 ms/model average,
102 ms worst case — down from 409 ms. Dense interior kits get a reduced ray
budget, and the hemisphere distributes over the ACTUAL ray count rather than
the nominal one; without that fix a reduced budget samples only near the normal
and reads as uniformly unoccluded. The 4x speedup moved the crevice share
14.7% -> 15.0%, i.e. cost nothing visually. Nothing in the library falls below
0.40 occlusion — the floor clamp is what keeps low-poly art out of the mud.
Contact AO needed one fix found by rendering it: an ellipse inscribed in a
square footprint pulls away from the corners, so a castle piece read as
standing in a spotlight rather than touching the ground. It is a squircle now
(|x|^4+|z|^4=1) with segments landing on the corners and edge midpoints.
STEERING FIXED ONCE, AT THE SOURCE. New libs/game/sim/heading.rs states the
convention in one place — forward is -Z, right is +X, POSITIVE YAW TURNS LEFT —
with heading_to_forward/right, forward_to_heading, steer_to_yaw_rate,
heading_delta. Seven tests read as statements of intent ("steering right
decreases heading") so a future sign flip fails loudly. The car's torque and
its autodrive route-follower both route through it and the inline atan2 calls
are gone. The inversion was exactly the trap the module now documents: positive
steer produced positive yaw, which turns left.
DOUBLE BRAINS, found by wiring: spawn_blocks ran unconditionally after
build_world, so every villager got a SECOND Npc block — two brains steering one
body — plus a second car. 28 NPCs for 14 entities; now 14.
The car is a real mesh (toy-car-kit/vehicle-truck) found by description and
scaled from its own bounds onto the chassis, box hidden. The rigid body stays
the physics.
BIG WORLD RENDERS: ARCADE_WORLD=big, street demo still default. 596 props, 217
colliders, 64 draw items (per-pack atlas batching working), 611 shadow casters,
14 NPCs, 63 of 64 models loaded, 15 ms to plan. 506,962 TRIANGLES — that will
not fit a Quest, and roads are 382 of 596 placements, so road decoration and
distant woods scatter are the first cuts a governor should make.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A POST-PASS, not a sweep change. The sweep carries the 0.55 step-up,
CONTACT_SKIN and the terrain-cliff logic, and every existing contract was
written against it, so it is untouched. separate_movers runs after the whole
integration loop — which also makes the result independent of who stepped
first — and before rider pinning, which stays authoritative.
HORIZONTAL ONLY, resolving the least-penetration axis of x/z. Resolving
vertically is exactly how characters end up standing on each other's heads; an
overlapping pair is pushed apart on the ground plane and a stack unpicks
itself.
Three FIXED relaxation passes, deliberately not convergence-based: an
early-exit on "nothing moved" makes the result depend on iteration order, and
this has to be bit-reproducible. Broad phase is a uniform grid sized 2x the
widest half, with buckets as a sorted (cell_key, index) array rather than a
hash map — allocation-light AND ordered without a second sort. That replaced a
hash map of per-cell Vecs and took allocations from 617/tick to ~15.
Each shove is clamped by sweep_axis against the solid world. Without that, a
crowd pressed against a wall squeezes its outermost members straight through.
push_mass weights the split by the OTHER body's mass, so equals each give half
and a player at 4.0 shoulders through NPCs at 1.0. 0.0 — the Default — READS
AS 1.0, not as weightless: a literal zero would make every default-constructed
mover infinitely shovable and divide by zero when two met. Same discipline as
`hidden` over `visible`.
Projectiles are excluded, and that is CORRECTNESS not taste: collect_touches
reports a strike from the overlap itself, so separating projectiles would mean
a bullet could never touch anyone. Sensors, collide:false decor and attached
riders are skipped too.
50 packed movers 0.023 ms/tick
200 packed movers 0.123
12 villagers + 500 static 0.107
200 movers + 500 static 2.020
Packed crowds where everyone overlaps a neighbour — the honest worst case. The
200-among-500-statics figure is dominated by the per-shove static clamp; at the
realistic 12-50 NPCs it is 0.1-0.25 ms. The fix if 200+ becomes normal is
accumulating pushes and clamping once per mover per pass, deliberately not done
because it changes Gauss-Seidel to Jacobi and the numbers don't justify it.
THE GOLDEN HASHES DID NOT CHANGE, and that is genuine rather than lucky:
mover_scene's walkers start 1.7 apart with 0.4 halves and diverge, and its only
other mover is an attached rider, so no pair ever overlaps and the pass is
inert. Nine new tests prove separation works; the unchanged goldens prove it
does nothing where movers never meet.
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>
Three complaints from looking at the running app, all one problem: the world
didn't behave like a place.
COLLIDERS COME FROM EACH PROP'S OWN PRIMITIVES, not its AABB. Kenney authors a
house as walls + roof + door frame and a tree as trunk + canopy, so
StaticModel::parts records per-primitive bounds during the existing vertex
bake (they were being merged away). collider_parts() drops boxes under 10% of
the model's span, merges near-coincident ones, caps at 8 — low-res by design.
Policy falls out of the decomposition: buildings/fences/rocks take every
qualifying box; trees keep only parts both narrow and low, so the trunk blocks
and you walk under branches; lamps and decals take none. A prop whose parts all
filter out gets a synthesised box (trees a narrow post), because silently
reverting to walk-through scenery is the bug being fixed — a real catch, since
that fallback first shipped for Solid only and colliders dropped 39 -> 20 when
single-mesh pines found no trunk.
`hidden` rather than `visible`, deliberately: Entity derives Default, so the
field defaulting to false must be the UNUSUAL case. A `visible` flag would make
every default-constructed entity invisible — the same trap as the zero-seed rng
and the zero-gravity bodies this codebase has already been bitten by twice.
Proven by test, not by eye: a walker stops at a house wall but passes through
its DOORWAY (this fails with a single AABB), a trunk blocks while its canopy
doesn't, hidden colliders still block. One test initially "failed" because 120
ticks at 4 u/s travels exactly 8 units — it was measuring the tick budget, not
the collider.
STATIC PROPS NOW CAST. rebuild_static_shadows only walked entities, and props
are ModelInstances whose colliders are hidden, so trees and houses cast
nothing. Placed models feed the same baked layer, caster points sampled from
the model mesh (extremes always kept, then strided to ~48 — a stride alone
misses roof ridges) so a pine's shadow tapers. Cached on (render_rev,
bake_generation, models_rev), merged into one geometry, one draw.
THE SCENE IS COMPOSED: five suburban houses set back from a road all FACING it
(uniform facing is the point — random yaw reads as debris), lamps on one verge,
benches on the other, a fence line, three separated tree stands rather than
uniform sprinkling, and the physics demo gathered into a builder's yard. Props
scale to a target height from their own bounds, since a fixed multiplier gives
a 12-unit bench beside a 2-unit house. Exhaust only emits above 3 u/s (a parked
car under its own smoke column read as a bug).
44 props, 39 colliders, 7 draw items, 15.8k triangles.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A generated game handed box3d an infinite extent and took the whole process
down — found by the eval harness actually generating games, not by reading
code.
NaN was already absorbed (Rust's `max` returns the non-NaN operand), but
INFINITY survives it and poisons every plane normal to NaN. box3d's face query
then never beats its -f32::MAX starting separation, leaves max_face_index at
the -1 sentinel, and convex_manifold.rs casts that sentinel through u8 into
255 and uses it to index a 6-element array.
The port is FAITHFUL to upstream C here — convex_manifold.c does the same
(uint8_t)maxFaceIndex cast; C reads garbage where Rust panics — so box3d is
not the place to diverge. The boundary is: never hand the solver a value it
cannot reason about. sane_extent() clamps non-finite and out-of-range
dimensions (and density) to a workable range.
Regression test covers +inf, -inf, NaN, 0 and negative extents: none may panic
and all must leave a finite pose after 30 ticks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two bugs, one visible symptom — the Knight strolling through the crate stack.
1. Rigid bodies were absent from the mover sweep set and from raycast /
camera-boom queries. When box3d dynamics landed, movers kept sweeping only
against Static|Kinematic, so a character (and a bullet, and the chase
camera) passed straight through every crate. Rigid poses are read back from
box3d at the end of the previous tick, so at snapshot time a rigid is as
settled as a kinematic and belongs in exactly the same set.
2. Fixing (1) exposed a deeper one. Clamping left the two boxes EXACTLY flush
(|d| == sum of halves), where float error decides the next axis' overlap
test either way — and a "yes" sent the falling mover UP onto the crate,
straight through the documented 0.55 step-up limit. It then walked along
the crate top. CONTACT_SKIN (1e-3) makes resting contact stop a hair short
of flush, so contact is unambiguous instead of borderline. Verified: a
walker into a 1.0-tall crate now stops at its face (x 1.20, y 0.50) rather
than climbing to y 1.50.
mover_is_blocked_by_a_rigid_body pins the behaviour. The mover golden hash is
re-baselined once, deliberately — the skin shifts every clamped position by
1e-3, and the movement it now describes is correct rather than merely
different. The reason is recorded at the assertion so a future change to that
hash needs the same justification.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
step_world cloned two things per tick purely to dodge a borrow: the whole
Terrain (heights AND colors) and every static/kinematic Entity at 208 bytes
each. Terrain dominated — a 257^2 field is 1.3 MB/tick, 79 MB/s of memcpy at
60 Hz, on a world containing seven entities.
- Terrain: copy -> borrow, splitting the struct borrow the way the bottom of
the same function already did
- Statics: 208-byte Entity -> 48-byte Solid. This one MUST stay a copy —
movers sweep against kinematic poses from BEFORE this tick's integration
and that ordering is load-bearing — but it only ever needed
id/kind/pos/half/vel
- owner_pose: skip building the table when nothing is attached (most worlds)
scene ms/tick B/tick
demo 0.002 -> 0.003 15,140 -> 4,796 (-68%)
racing-ish (129) 0.007 -> 0.002 362,316 -> 8,576 (-98%)
terrain 257 0.019 -> 0.001 1,323,964 -> 896 (-99.93%)
large (500 static) 0.063 -> 0.056 591,386 -> 82,382 (-86%)
stress (2000 static) 0.583 -> 0.457 2,353,936 -> 327,812 (-86%)
Result-neutrality proven, not assumed: new mover_golden.rs covers what
rigid_dynamics.rs doesn't reach (terrain cliffs/floors, sweeps, platform
carry, attach pin, projectile lifetimes, auto-face) and its golden hash is
identical before and after — verified by stashing only the source changes
and re-running, not by re-baselining. Also includes a test pinning the
pre-integration snapshot ordering, so a future "obvious" simplification that
reads live positions gets caught.
Leak check: 36,000 ticks (10 simulated minutes) of a busy world with
projectiles spawning and expiring — RSS flat at 3.8 MB, +0.4% drift.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Committed together: both streams landed in libs/game/script, so splitting
them would produce two commits that don't compile.
M6 — packaging and sharing
- libs/zip_file gains a writer (store + deflate); real `unzip -t` validates
our archives in an interop test. Packing is deterministic (fixed
timestamps, sorted entries), so a package can be addressed by its own
sha256 — which is what makes the registry's digest check mean anything
- libs/game/pkg: .arcade format (game.splash + manifest.toml + assets),
total manifest parsing (attacker bytes always yield a Manifest or an
error, never a panic; non-finite numbers refused rather than defaulted),
registry client that verifies sha256 INSIDE download so tampered bytes
never reach the extractor
- Hardened extraction: absolute paths, drive letters (C:x is absolute on
Windows), UNC, backslashes, .., NUL/control chars, symlink members (via
mode bits), duplicate names (the ambiguity IS the attack), declared-size
caps checked before decompressing plus a post-decompress check, entry/
total/archive caps, and a post-join re-check that the resolved parent is
still inside the destination — which catches a pre-existing symlink the
name test cannot see. 4000-round mutation fuzz with a canary file beside
the destination; a 320 MB deflate bomb under 1 MB on the wire is refused
- Capability stripping rebinds fs/run/net to FRESH EMPTY OBJECTS rather
than shadowing known verbs, so there is no hole the day someone adds one.
Applied before the game handle is registered. Vacuity guard: an unstripped
isolate genuinely reads a file, so the sandbox tests can't pass for
unrelated reasons. Browser-installed games load Trust::Downloaded
M7 — pretty pass
- GameSun adopts draw::SceneSun (axis-converted: SceneSun is map-space
y-south/z-up, games are y-up). Shaders compute hemisphere ambient +
direct instead of each hardcoding its own split; defaults collapse the
new formula to the old constants exactly, so unifying did not restyle
existing games. write_into is the single write path — "one sun" is
compiler-enforced
- Projected shadow geometry: the caster's silhouette along the sun, fitted
in the sun's own (u,v) frame, so it stretches as the sun swings. Nearest
N casters get projection, the rest blobs; one instance in the existing
alpha batch, no extra pass. 0.6us for 24 casters
- Two pre-existing shadow bugs found via capture: the pipeline blends
premultiplied, so unpremultiplied dark RGB ADDED light instead of
removing it; and shadows were fogged, mixing them toward the bright
horizon so a distant shadow came out lighter than the ground it darkened
- Particles are structurally isolated from the sim: GameWorld has no
particle field and step_world has no particle code — the renderer owns
simulation and its own RNG. particles_never_advance_the_world_rng
interleaves particle verbs with real rand() draws over 32 rounds and
asserts both the RNG state and the drawn stream are identical
- game.sfx_at with listener-relative gain/pan and a near-field ease so a
sound at your feet doesn't flip channels; 2D verbs unchanged
- apps/arcade/BUDGETS.md: measured particle/sim costs, Quest columns marked
as estimates (the real particle limit is fill rate, not CPU)
Tape probe BYTE_IDENTICAL. Not done: arcade has no audio backend, so
positional sound is implemented and tested but not audible there yet.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A room of devices now plays one game: host simulates, clients send input
and render replicated truth.
- libs/game/sim/player.rs: Players roster on GameWorld, slot 0 is always
this device. Player 0's input stays in the world's original held/pressed/
pad/cam_yaw fields and is mirrored into the roster, which is what keeps
single-player numerics bit-identical (tape gate confirms). Ids are never
reused, so a stale reference resolves to None, never to somebody else
- The camera-movement knot resolved: world.player_move(p) rotates that
player's axes by THEIR cam_yaw, carried in their input packet. Player 0's
branch is the original expression character-for-character (the f32 cos
widened to f64 kept deliberately — tidying it would move the numbers)
- libs/game/session/replication.rs: Shared = pos/vel/size/kind/tag;
Derived = facing/anim/scale/glow/blob shadows, recomputed client-side and
costing zero wire bytes; Local = camera/audio/effects. Statics never enter
the per-tick stream
- Protocol (additive, version unchanged): EntityDesc + Descriptors message
splits rare reliable construction data from volatile unreliable state —
without it a joiner sees poses for entities it cannot build. EntityDesc
carries pos because statics never appear in the state stream (the
late-joiner test caught ground arriving at the origin)
- Script: game.players/player_name/player_entity/player_input/bot/on_join/
on_leave; blocks gained owner: PlayerId so a car reads its own driver
- Arcade: ARCADE_HOST=1 / ARCADE_JOIN=<addr>; clients skip world
construction and don't simulate
- Racing wire volume, 6 players x 60Hz x 200 entities: 2400 pps,
20.9 Mbit/s up (audit projected 74 Mbit for the XR stack). Asserted in
racing_scenario_wire_volume_fits_a_living_room
Two more not-a-playable-default bugs, same class as M1b's rng-at-zero:
Entity::default() leaves gravity_scale 0 (weightless wheels) and ground
without friction gives no traction — only the DSL path filled these in.
255 tests green; tape probe BYTE_IDENTICAL; xr/arcade/gamemaker build.
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>
- libs/game/sim/dynamics.rs: box3d world inside GameWorld, body mirror
RECONCILED against the sorted entity list per tick (merge-walk), so
retain/rollback/reset are correct by construction. Statics + kinematics
mirrored (kinematics via target transforms so resting rigids inherit
platform velocity); smooth terrain as a box3d heightfield
- New BodyKind::Rigid + body:"rigid" with density/friction/restitution;
game.push = mass-scaled impulse; set_pos/set_vel detected via bit-exact
pose caches (no new dispatch arms). Sphere rigids roll on real spheres
- Entity.orient quat read back per tick; renderer builds quat instance
transforms for rigids (no shader change). Step order: mover sweep
verbatim -> reconcile -> world_step(dt, 4) -> readback; rigid-free
worlds skip the solver
- GameWorld stays Clone via box3d snapshot round-trip (bit-identical
continuation proven by test)
- Determinism: double-run equality + golden hash 0xa8a2baf71e4a564f
(aarch64, debug and release). Perf: 0.038 ms/tick for 100 movers +
50 rigids + 65x65 terrain (budget 2 ms)
- Tape probe BYTE_IDENTICAL; box3d crate untouched; arcade demo gains a
kicked crate stack (captures verified: settles upright, then topples
with rotated resting poses)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Entity lookup O(log n): shared entity_index_sorted helper, single
push_entity spawn path with ascending-id assert + per-tick sorted
debug_assert — the sorted-Vec invariant is now enforced, not hoped
- WorldSnapshot captures next_id: failed-eval rollback can no longer mint
colliding ids under surviving entities (review defect fixed)
- game.log buffered (1s/16KiB/eval-boundary flush), agent RPC poll gated
on one .agent dir-mtime stat — no per-tick file I/O left
- Camera rig authoritative on GameWorld (orbit/chase state); widget keeps
only device-input accumulation; mailbox drain order unchanged
- Per-tick cumulative script budget: with_instruction_limit reports
consumption (incl. trap-wipe subtlety), gamemaker tick holds ONE 500k
pool across on_tick + timers + touch events
- Tape probe verified BYTE-IDENTICAL vs pre-cleanup reference
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- libs/game/math: cross-arch bit-deterministic f32 kernel (f64-internal
fdlibm-style polynomial kernels, exactly-rounded IEEE ops only, zero
platform-libm transcendentals). Parity golden hashes recorded on aarch64;
equal hashes on every other target IS the determinism test
- libs/game/sim: the gamemaker world moved verbatim (entity/terrain/world/
queries/step — float expression order preserved so tapes replay
bit-identically). No Cx, no draw types, no ScriptObjectRef: script
callbacks are CallbackSlots resolved through a generation-tagged
host-side CallbackTable in game_view.rs; sim reset no longer touches audio
- examples/gamemaker: game_view.rs 5545 -> 4640 lines (script boundary,
rendering, input devices, host I/O remain); per-tick args/input objects
built via unchecked pushes + release_transient (flat-heap tick path)
- apps/arcade: Makepad Arcade app shell + workspace membership
- Verified: headless sandbox3d fixture (90 entities) boots end-to-end;
gamemaker/sim/math/script-test suites green
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>