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>
libs/game/coedit (zero deps, so merge logic is testable without a socket or
a VM). Not a CRDT: transactions are host-serialized into an append-only
generation history, and a conflict is answered by handing the author the new
base so THEY re-derive their intent.
- Transaction carries the author's whole intended file, not a patch: the
diff against its declared base is derived host-side, so a stale or
malformed patch can never be applied — and an AI writes whole files anyway
- diff3 over lines with LCS anchors. Merge::Conflict deliberately carries NO
merged text: a half-merged game file that still parses is worse than an
honest rejection. Conflict -> Rebase{new base + per-generation summary of
what landed underneath}. An edit already present in the tip is refused as
NoChange rather than appended as an empty generation
- Leases are advisory as designed: a test asserts a submit SUCCEEDS while
another author holds the lease. They shape who chooses to edit; they never
gate the log. TTL expiry means a crashed author cannot lock a region
- Wire: coedit is reliable-channel ONLY — a test signs a valid submission,
sends it by datagram, and asserts it is ignored, so no datagram can
rewrite the game. Every response is addressed, never broadcast
- Arcade bridge routes in exactly one place; local agent and remote authors
share the queue and the rules, and a test asserts the local agent gets
rebased identically when it loses a race. Remote players map to
AuthorId(player+1) so a client holding player id 0 cannot impersonate the
host's agent
- 200-round deterministic fuzz: 4 authors submit against deliberately stale
bases, rebase, resubmit — asserting linear append-only numbering, no
generation claiming a base from the future, and that replaying accepted
diffs from generation 0 reproduces the head exactly
Two real bugs found building it: validate used `?` on the base lookup, so an
unknown base returned "no refusal" instead of UnknownBase; and
MAX_COEDIT_SOURCE (512 KiB) exceeded MAX_FRAME_BYTES (256 KiB), so the host
would have accepted a source it could never hand back inside a Rebase,
stranding the next author on an answer that never arrives. Both now 192 KiB.
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>