Problem: no gamepad, no input contexts, no input buffering, no local players. Verified on main: zero hits for getGamepads and unadjustedMovement in packages/; packages/core/src/game/controlGate.ts is one boolean; packages/core/src/runtime/inputSnapshot.ts is one global held set; CameraKind = "splitScreen" in packages/core/src/visibility/camera.ts is unused.
Tasks, one PR each, in order:
-
Gamepad model (core). New packages/core/src/input/gamepadModel.ts: GamepadSnapshot { id: string; axes: number[]; buttons: { pressed: boolean; value: number }[]; connected: boolean }, GamepadBindings mapping action names to pad:<buttonIndex> and padaxis:<axisIndex>[+-] codes (extend ActionBinding code parsing in packages/core/src/input/actionBindings.ts to accept these strings), resolveGamepadFrame(snapshot, bindings, { deadzone: { kind: "radial" | "axial"; inner: number; outer: number }; curve?: number }) -> { held: string[]; analog: Record<string, number> }. GamepadGlyphSet data for xbox, playstation, nintendo, generic and bindingLabel returns the glyph text for pad codes. Tests: deadzone, curve, glyphs. bun run gen. CHANGELOG Added.
-
Gamepad source (shell). New packages/shell/src/input/gamepadSource.ts: polls navigator.getGamepads() in useFrame, merges the resolved frame into the tracker/analog refs FrameDriver reads, and exposes rumble(userId, { strong, weak, ms }) on ctx.input via vibrationActuator.playEffect. Mount it in GamePlayerShell.tsx. Since Playwright cannot attach a pad, add a synthetic GamepadSnapshot injector (?gamepad=1 dev overlay) and a test on the pure merge.
-
Action contexts. New packages/core/src/input/actionContexts.ts: createActionContextStack() with push({ id; codes: ActionCodesMap; passthrough: boolean }), pop(id), active() -> ActionCodesMap (merged top-down until a non-passthrough), snapshot, restore. Replace the boolean in controlGate.ts with a menu context push, keeping playControlsActive as a wrapper. Wire the tracker in packages/shell/src/input/ to read active(). Tests.
-
Input feel helpers. New packages/core/src/input/inputBuffer.ts: createInputBuffer({ windowMs }) with press(action, nowMs), consume(action, nowMs) -> boolean, coyote(groundedAtMs, nowMs, windowMs) -> boolean, hold(action, nowMs) -> ms held, doubleTap(action, nowMs, gapMs), snapshot/restore. Consume it in packages/core/src/movement/playerMovement.ts for jump (buffered jump and coyote time behind movement.jumpBufferMs / coyoteMs, default 0 so behavior is unchanged). Tests.
-
Raw mouse. In packages/shell/src/input/mouseLook.ts request requestPointerLock({ unadjustedMovement: true }) with a fallback to the plain call when the promise rejects.
-
Local player slots. New packages/core/src/runtime/localPlayers.ts: createLocalPlayers({ maxSlots }) with assign(deviceId) -> slotId (hot-join on first input), slots(), one InputSnapshot per slot, snapshot/restore. ctx.player stays the primary; add ctx.players.local(slotId) returning { userId; input }. The game loop spawns one entity per slot through onNewPlayer. Tests.
-
Split-screen viewports. New packages/core/src/game/viewports.ts: ViewportDef { slot: string; rect: [x, y, w, h] (0..1); camera: GameCameraConfig }; packages/shell/src/camera/Viewports.tsx renders each viewport with gl.setViewport/setScissor in a useFrame at render priority, one camera rig per slot following that slot's entity, and a HUD root per viewport. Add viewports?: ViewportDef[] to the presentation config. Shots of two viewports.
-
Probe. Two-pad couch example under examples/ with hot-join. Closes #1686.
Rules: wait for bun run agent:bootstrap --check; branch claude/<slug> off origin/main; claim comment first; one task per PR with Refs #1686; bun run gen after export changes (JSDoc on every export; create* factories need snapshot/restore); CHANGELOG bullet; check-types and test on core, shell, react as touched plus check-stateful-ratchet, check-doc-symbols, check-orphan-ratchet; merge origin/main before pushing; shots via bun run shoot; squash auto-merge; fix CI on the same branch.
Problem: no gamepad, no input contexts, no input buffering, no local players. Verified on main: zero hits for
getGamepadsandunadjustedMovementinpackages/;packages/core/src/game/controlGate.tsis one boolean;packages/core/src/runtime/inputSnapshot.tsis one global held set;CameraKind = "splitScreen"inpackages/core/src/visibility/camera.tsis unused.Tasks, one PR each, in order:
Gamepad model (core). New
packages/core/src/input/gamepadModel.ts:GamepadSnapshot { id: string; axes: number[]; buttons: { pressed: boolean; value: number }[]; connected: boolean },GamepadBindingsmapping action names topad:<buttonIndex>andpadaxis:<axisIndex>[+-]codes (extendActionBindingcode parsing inpackages/core/src/input/actionBindings.tsto accept these strings),resolveGamepadFrame(snapshot, bindings, { deadzone: { kind: "radial" | "axial"; inner: number; outer: number }; curve?: number }) -> { held: string[]; analog: Record<string, number> }.GamepadGlyphSetdata forxbox,playstation,nintendo,genericandbindingLabelreturns the glyph text for pad codes. Tests: deadzone, curve, glyphs.bun run gen. CHANGELOG Added.Gamepad source (shell). New
packages/shell/src/input/gamepadSource.ts: pollsnavigator.getGamepads()inuseFrame, merges the resolved frame into the tracker/analog refsFrameDriverreads, and exposesrumble(userId, { strong, weak, ms })onctx.inputviavibrationActuator.playEffect. Mount it inGamePlayerShell.tsx. Since Playwright cannot attach a pad, add a syntheticGamepadSnapshotinjector (?gamepad=1dev overlay) and a test on the pure merge.Action contexts. New
packages/core/src/input/actionContexts.ts:createActionContextStack()withpush({ id; codes: ActionCodesMap; passthrough: boolean }),pop(id),active() -> ActionCodesMap(merged top-down until a non-passthrough),snapshot,restore. Replace the boolean incontrolGate.tswith amenucontext push, keepingplayControlsActiveas a wrapper. Wire the tracker inpackages/shell/src/input/to readactive(). Tests.Input feel helpers. New
packages/core/src/input/inputBuffer.ts:createInputBuffer({ windowMs })withpress(action, nowMs),consume(action, nowMs) -> boolean,coyote(groundedAtMs, nowMs, windowMs) -> boolean,hold(action, nowMs) -> ms held,doubleTap(action, nowMs, gapMs), snapshot/restore. Consume it inpackages/core/src/movement/playerMovement.tsfor jump (buffered jump and coyote time behindmovement.jumpBufferMs/coyoteMs, default 0 so behavior is unchanged). Tests.Raw mouse. In
packages/shell/src/input/mouseLook.tsrequestrequestPointerLock({ unadjustedMovement: true })with a fallback to the plain call when the promise rejects.Local player slots. New
packages/core/src/runtime/localPlayers.ts:createLocalPlayers({ maxSlots })withassign(deviceId) -> slotId(hot-join on first input),slots(), oneInputSnapshotper slot, snapshot/restore.ctx.playerstays the primary; addctx.players.local(slotId)returning{ userId; input }. The game loop spawns one entity per slot throughonNewPlayer. Tests.Split-screen viewports. New
packages/core/src/game/viewports.ts:ViewportDef { slot: string; rect: [x, y, w, h] (0..1); camera: GameCameraConfig };packages/shell/src/camera/Viewports.tsxrenders each viewport withgl.setViewport/setScissorin auseFrameat render priority, one camera rig per slot following that slot's entity, and a HUD root per viewport. Addviewports?: ViewportDef[]to the presentation config. Shots of two viewports.Probe. Two-pad couch example under
examples/with hot-join.Closes #1686.Rules: wait for
bun run agent:bootstrap --check; branchclaude/<slug>offorigin/main; claim comment first; one task per PR withRefs #1686;bun run genafter export changes (JSDoc on every export;create*factories need snapshot/restore); CHANGELOG bullet;check-typesandteston core, shell, react as touched pluscheck-stateful-ratchet,check-doc-symbols,check-orphan-ratchet; mergeorigin/mainbefore pushing; shots viabun run shoot; squash auto-merge; fix CI on the same branch.