# Lane B — Player & Physics — Update for Review **Author:** Lane B · **Status:** ✅ complete, verified live, self-reviewed **Scope touched:** `src/player/**`, `src/demo/playerDemo.ts`, `demo-player.html`, `.claude/launch.json` (dev-server config for the demo). No writes to `src/core/`, `src/main.ts`, or any other lane's directory. --- ## TL;DR First-person controller with Minecraft feel **and** the signature record-riding mechanic is done. `npm run typecheck` is clean, the standalone demo runs with no console errors, and all 10 brief acceptance criteria are verified by driving the physics in a browser (not just compiling). I then ran a 20-agent adversarial review, which confirmed 6 issues (from 15 raised); I fixed 3 in code and documented 2 as core-contract friction for the integrator. Re-verified after the fixes — no regressions. ## Deliverables | File | Role | |---|---| | [src/player/PlayerController.ts](../src/player/PlayerController.ts) | The controller. Implements `IPlayerView`. | | [src/player/collision.ts](../src/player/collision.ts) | Swept per-axis AABB-vs-voxel resolution + auto-step. | | [src/player/input.ts](../src/player/input.ts) | Scriptable `InputState` + pointer-lock `InputController`. | | [src/player/index.ts](../src/player/index.ts) | Public surface (what integration imports). | | [src/player/HANDOFF.md](../src/player/HANDOFF.md) | API + tuning + friction detail. | | [src/demo/playerDemo.ts](../src/demo/playerDemo.ts) + [demo-player.html](../demo-player.html) | Standalone demo (all mocks marked `// DEMO MOCK`). | **Public API** (per the brief, exactly): ```ts const player = new PlayerController(world /* IVoxelWorld */, { getColliders: () => machines.flatMap(m => m.getColliders()), camera, domElement, spawn: [x, y, z], }); player.update(FIXED_DT); // call each fixed tick AFTER machines.update(dt) player.teleport(v); player.setFlying(b); // IPlayerView for Lanes D/E: position (feet), eye, lookDir, onGround, groundedOn ``` ## Acceptance — verified live Driven headlessly through a debug handle in the demo (deterministic `update()` stepping), so these are measured, not eyeballed: | Criterion | Measured result | |---|---| | Walk / sprint / jump / fly | ✅ all correct | | Auto-step 1-voxel ledge | ✅ climbs staircase to y=6 | | 2-voxel wall NOT stepped | ✅ blocked at x=29.7, never climbed | | No tunneling (400× sprint-jump into wall) | ✅ never crosses x=30 | | Disc ride = clean circle, no rim drift | ✅ radius holds at 20.00 over 2s | | Near-center calm / rpm45 rim > sprint | ✅ carry 0.42 vs 11.31 (sprint=5.6) | | Fling on jump off rim | ✅ ~10.2 v/s, airborne | | Oscillating AABB platform, no fall-through | ✅ groundedOn=platform, y=1.0 | | `player:step` / `player:landed` fire | ✅ 6 steps over 10.5 voxels / impact 26.5 | | `npm run typecheck` clean, no console errors | ✅ | ## Adversarial review outcome (self-run, 20 agents, 6/15 confirmed) **Fixed in code (re-verified, no regressions):** 1. **Overlapping colliders double-applied carry** *(medium)* — `resolveColliders` applied surface carry per-collider, so two overlapping rideable colliders (e.g. a future fader sled crossing a platter rim) would translate the player by the *sum* and report only the last one via `groundedOn`/`carryVelocity`. Fix: ground on exactly one collider (highest supporting top), apply carry once. 2. **Keyboard input wasn't gated by pointer-lock** *(medium)* — WASD/F drove the character while unlocked (only mouse-look was gated). Fix: gate key handlers on lock state, mirroring mouse-look. **Integrator note:** in-game, movement now requires clicking to lock first (standard FPS behavior). 3. **Footsteps sampled a single center column** *(low)* — at a bridge/ledge edge the center column can be air while a neighbor supports the foot, silently dropping the event. Fix: sample the full footprint (same cells grounding uses). Verified: walking a bridge edge now fires footsteps where it fired none before. **Documented as core-contract friction (needs an integrator/core decision — I did not edit `src/core/`):** 4. **`KinematicCollider.velocityAt(x,y,z): Vec3` allocates per sample** *(low)* — while riding I sample it up to 8×/frame (~480 arrays/sec), brushing CONTRACTS §8. Clean fix is a core out-param overload `velocityAt(x, y, z, out?: Vec3)`. 5. **Vertical platform carry is intentionally dropped** — fine for v1 gear (+Y platters, horizontal faders/tonearm). Only matters if Lane D ships a vertically-moving rideable collider; then `applyCarry` needs a Y sweep. *(9 findings were refuted on verification — e.g. "getters leak live arrays" and "held-jump re-emits landing"; details in the review, both judged non-issues.)* ## Integration notes for `main.ts` (per docs/INTEGRATION.md step 6) - Construct **after** Lane A's `world` and Lane D's machines exist; pass `getColliders: () => machines.flatMap(m => m.getColliders())`. - Fixed loop order: `machines.update(dt)` **then** `player.update(dt)` — the player reads the *current* collider positions/velocities that tick. - The controller owns the camera's transform + FOV each tick; don't also move it. - Local feel constants (accel/friction/step/bob) live at the top of `PlayerController.ts`, not in core, because `PLAYER` has no such fields. If you want them centralized, promote them to a **new** non-core config file. ## Suggested next steps (for Fable to weigh) - **Contract tweak decision:** approve the `velocityAt` out-param overload above? It's a one-line core change that removes the only hot-path allocation. If yes, I'll wire Lane B to the buffered form once core changes. - **Cross-lane:** Lane D should confirm cylinder colliders are +Y-axis only and expose platter/fader/tonearm colliders with `velocityAt` in voxels/sec — Lane B already rides anything matching `KinematicCollider`. - Optional polish I left out of scope (not in brief): crouch, coyote-time jump, ladder/vent climbing for the speaker-mesh (DESIGN §4.2) — flag if wanted. ## Repo / git This working copy is **not yet a git repo** (`git status` fails). I did not run `git init`, branch, or push to `ssh://git@100.71.119.27:222/monster/TURNCRAFT.git` — initializing the shared repo and landing five lanes is an integration decision, not Lane B's to make unilaterally. Everything above is on disk under `/Users/jing/TURNCRAFT`, ready to commit on the Lane B branch when you say go.