# Recipe — controls (bindings → contexts → devices → feel)

**What this wires:** a game's controls from small parts: named actions, layered contexts that swap bindings live, keyboard/mouse and gamepad on the same actions, and per-axis feel. There are no control presets; every game names its own actions and codes.

## The seams

- **Actions.** `defineGame({ input: { action: codes } })` maps action names to codes. Keyboard codes are `KeyboardEvent.code` (`KeyW`), pad buttons are `pad:<index>` and stick or trigger directions are `padaxis:<index>+`/`-` in the standard mapping. `bindingLabel(code, "xbox")` gives the glyph text for prompts. Sim code reads actions only: `ctx.input.isDown`, `justPressed`, `value` (0..1, analog when a stick or trigger drives it) and `axis(bindings)`.
- **Contexts.** `actionContextStack(ctx)` (`@jgengine/core/game/controlGate`) layers action maps over the base input. `push({ id, codes, passthrough, axes?, shaping? })` swaps bindings immediately: the shell re-binds keyboard, touch and pad on the next frame, and a key held across the swap keeps counting toward whatever it maps to now. `passthrough: false` hides everything below (menus, a parked vehicle); `passthrough: true` adds or overrides a few actions (a build mode on top of on-foot). The stack has `snapshot`/`restore`, so the current mode survives a save.
- **Overlapping menus.** `suspendPlayControls(ctx)` returns one idempotent release function per owner. Acquire while a menu is open and release on close, context replacement or unmount; controls resume only after the final owner releases and the existing play gate permits it. Shell settings own this lease automatically. Binding snapshots omit leases, and restoring bindings preserves the current context's live owners. Suspension clears held keyboard, pointer and analog input, so resuming requires fresh input; it does not pause a shared realm. Ordinary binding-context swaps retain held input.
- **Axes and feel per context.** A context can carry `axes` (axis bindings by action name) and `shaping` (serializable `createAxisShaper` profiles). Read them with `activeAxes()` when `version()` changes and `retune` the shaper, so on-foot movement and driving steer each get their own ramp and curve without a branch per mode.
- **Pad feel.** `defineGame({ gamepad: { deadzone, curve, triggerDeadzone } })` shapes sticks and triggers before values reach `ctx.input`; the axis shaper only adds ramps and speed scaling on top.
- **Timing.** `createInputBuffer` (`@jgengine/core/input/inputBuffer`) buffers early presses, coyote time, hold duration and double taps.
- **Simulation edges.** The shell retains a press released before the next fixed step; `ctx.input.justPressed` samples it on that step while `isDown` already reflects release. Headless callers can supply `presses` to `publishInput`; record/replay applies those edges only on the recorded tick. Canvas mouse bindings use `mouse0`/`mouse1`/`mouse2`, including native button chords; overlays do not begin gameplay presses. Focus loss, suspension and owner teardown clear pending local presses.
- **Hosted saves.** Persist `HostedGameRunner.state()` for reconstruction. Its host-only input admission and pending-edge state prevents retransmitted stored input from replaying a consumed press; `snapshot()` and client replication omit that bookkeeping.
- **Hosted retention.** Join or resume a member before publishing input; nonmember frames are ignored. The runner compacts elapsed input in memory while retaining continuous intent and every unconsumed future replay tick; a standalone `InputRecorder` keeps its full log. `inputRetention` configures departed admission marks (default 256 users, 5 minutes; either limit can be zero), with `nowMs` in wall-clock milliseconds. Recent reconnect protection is process-local and ends on expiry or oldest-entry eviction. `inputStats()` reports actual retained recorders, frames and admission marks for host health checks.
- **Local seats (couch co-op).** `defineGame({ localPlayers: { maxSlots: 4 } })` opens seats on one screen. The first pad to press a button shares the primary seat with keyboard and touch (`claimPrimary: "none"` keeps the primary keyboard-only); each further pad hot-joins its own seat, and the shell calls `loop.onNewPlayer(ctx, { userId, isNew: true })` for it, so spawn with `player?.userId ?? ctx.player.userId`. Read a seat with `localPlayers(ctx).local(slotId)` → `{ userId, input }` (`@jgengine/core/runtime/localPlayers`); iterate `slots()` in `onTick`. Seats use the same bindings and contexts as the primary, and the shell's walk controller moves every seat's entity. The table has `snapshot`/`restore`/`retune`.
- **Split-screen.** `defineGame({ viewports: { layout: "auto", split: "vertical" } })` draws one viewport per joined seat (`@jgengine/core/game/viewports`: `splitViewports`, `resolveViewports`, `viewportPixels`). The primary seat keeps the main camera rig; other seats get a chase or top-down follow camera (`viewports.camera`, defaulting to top-down when the main rig is), and the walk controller moves each seat relative to its own camera. `ViewportHuds` (`@jgengine/shell/camera/Viewports`) gives each seat its own HUD root. Post-processing is skipped while the screen is split. `bun run drive couch --param gamepad=2` shows two seats.
- **Haptics.** `ctx.input.haptics(userId)` holds named rumble channels: `set(name, { strong, weak }, priority?)` each tick for continuous feel, `pulse(name, { strong, weak, ms }, priority?)` for a fading hit. Higher priority ducks lower channels. The shell drives each seat's own pad; channels are local and never saved or sent.
- **Rebinding.** `createRebindSession` drives a conflict-aware "press a key" flow; overrides persist per game and apply under the context stack.

## Wiring a mode swap

```ts
const DRIVING: ActionContext = {
  id: "driving",
  codes: { exit: ["KeyF", "pad:3"] },
  passthrough: true,
  axes: { steer: { positive: ["steerRight"], negative: ["steerLeft"] } },
  shaping: { steer: { digital: { riseRate: 3, returnRate: 6 }, analog: { deadzone: 0.08, curve: 1.5 } } },
};

// enter: actionContextStack(ctx).push(DRIVING); leave: actionContextStack(ctx).pop("driving")
if (state.contextVersion !== contexts.version()) {
  state.contextVersion = contexts.version();
  const layer = contexts.activeAxes();
  state.bindings = { steer: layer.bindings.steer ?? { positive: [] } };
  state.shaper.retune({ axes: { steer: layer.shaping.steer ?? {} } });
}
const raw = ctx.input.axis(state.bindings);
const axis = state.shaper.shape(dt, raw, { analog: analogAxes(state.bindings, ctx.input.analog()) });
```

`bun run drive handling` runs this: F pops the driving context and a held W stops reaching the engine.

## Pitfalls

- Actions that exist only inside a context are published to `ctx.input` while the context is active, but the base `input` map is what the touch dock and settings menu list. Keep an action in the base map when touch players need it.
- `shaping` has no `scale` callback because contexts are serializable; apply speed-sensitive scaling on the shaper the game owns.
