Skip to content

Add Gamepad actions, and rebinding for keyboard and gamepad - #57

Merged
SandroMaglione merged 4 commits into
mainfrom
claude/gamepad-actions
Oct 22, 2026
Merged

SandroMaglione merged 4 commits into
mainfrom
claude/gamepad-actions

Conversation

@SandroMaglione

@SandroMaglione SandroMaglione commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

Requested by Sandro · project thread

Adds controller support to @typeonce/ritmo-browser, plus runtime rebinding so games can offer a controls settings screen. Spirit Call will build on it next.

Gamepad

  • Gamepad.actions(window, bindings, options) binds actions to buttons named by the standard mapping (south, west, rightTrigger, dpadUp, start, …), so a binding means the same thing on Xbox, PlayStation, and Nintendo pads.
  • A snapshot has actions (with the same ActionState as Keyboard), every buttons state by name whatever the bindings (for fixed menu navigation, and for a settings screen waiting for the button to bind), the left and right sticks with a radial dead zone (y grows down, as with Touch's stick), the analog leftTrigger and rightTrigger, connected, and the pad id.
  • The Gamepad API has no button events, so the pads are read on every animation frame (and on every snapshot()) until dispose(); a tap shorter than one update still counts, with nothing extra to call.
  • Follows the pad used last, and stays with it while it is in use. Only stick axes count as use, because some pads rest their triggers on extra axes at -1. A pad that disconnects releases everything it held. A host that throws (a blocked permission policy) counts as having no pads.
  • idle, scripted, recording, parseTimeline, and isButton, like the other devices. Timelines name buttons, not actions, so a replay follows the bindings as live play does.
  • GamepadHost is a structural subset of window (navigator.getGamepads plus animation frames), so the package still compiles without DOM types, and tests run in Node.

rebind

  • Keyboard.actions(...).rebind(next) and Gamepad.actions(...).rebind(next) keep the same action names (the types enforce this) and swap the keys or buttons from the next snapshot. An action held only through a key it lost reports released. A key held across the rebind is still tracked, so its release is seen.
  • Scripted keyboard actions ignore it, because their timeline names actions.
  • Breaking for a hand-written Keyboard.Actions: it now needs rebind() {}. This is noted in the changeset.

Review fixes (second commit)

  • rebind keeps the actions the source was made with: a rebinding object with extra keys (which TypeScript allows when it is not an inline literal) no longer adds actions at runtime.
  • A key or button held across a rebind holds a newly bound action only after a fresh press, on both devices, so an action never turns held without a press (the settings-screen case: the button pressed to bind is still down).
  • Timelines: an entry naming a held action/button in both press and release is a release then a new press (still a tap when it was up), and recordings emit it, so a release and press inside one update replays exactly. Applies to Keyboard and Gamepad.
  • New Keyboard.presses(host): every key press (bound or not) as { key, code }, for a settings screen waiting for the key to bind.
  • New Keyboard.parseRebinding(bindings, data) and Gamepad.parseRebinding(bindings, data): validate saved bindings into a Result<Rebinding<B>, InvalidRebinding> ready for rebind, without casts.

Review fixes (third commit)

  • rebind takes exactly the actions the source was made with: an extra action fails to compile (extra is not an action of these bindings) even from an object not written inline.
  • InputCapture.system requires the resource's value type to be exactly the snapshot type (an extra optional field used to pass); the error names InputCapture.SnapshotOf<typeof Source> as the fix. Breaking, noted in the changeset.
  • Gamepad.actions takes window and reads the pads on every animation frame; poll() is removed, so keyboard and gamepad share the snapshot / rebind / dispose lifecycle.

Review fixes (fourth commit)

  • Gamepad timeline entries take an optional connected; recordings write disconnects and reconnects, so a replay drops and regains the pad on the same frames (a disconnect releases everything and zeroes the sticks and triggers, as live).
  • integration/top-down now plays with a gamepad next to the keyboard: GamepadInput service, PadState declared with InputCapture.SnapshotOf, its own capture system, left stick (analog) or d-pad to walk, south to interact, with headless tests.

Checks

  • pnpm run check (typecheck, lint, type tests on both TypeScript targets, and the tests) and pnpm pack:check pass.
  • New tests are packages/browser/test/Gamepad.test.ts (17) and the Keyboard.rebind tests, plus the type tests in dtslint/Gamepad.tst.ts and dtslint/Keyboard.tst.ts.

Generated by Claude Code

Games had keyboard, pointer, and touch input but no controllers, and
bindings were fixed when the actions were made, so players could not
remap them. Gamepad follows Keyboard's shape (named actions, edges,
idle/scripted/recording/parseTimeline) and adds the sticks, triggers,
and every button by name for menus and settings screens. rebind() keeps
the action names and swaps the keys or buttons.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qkEJzyV5DVhoLVDMmoWJh
SandroMaglione and others added 3 commits October 22, 2026 19:07
- rebind keeps the actions the source was made with, so a rebinding
  object with extra keys can no longer add actions at runtime.
- A key or button held across a rebind holds a newly bound action only
  after a fresh press, on both devices, so an action never turns held
  without a press.
- Timelines play an entry naming a held action or button in both press
  and release as a release then a new press, and recordings emit it, so
  a release and press inside one update replays exactly.
- Keyboard.presses(host) reports every key press for a settings screen
  waiting for the key to bind.
- Keyboard.parseRebinding and Gamepad.parseRebinding validate saved
  bindings into a Result ready for rebind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- rebind takes exactly the actions the source was made with: an extra
  action fails to compile even from an object not written inline, and a
  Record<string, ...> is rejected since it cannot prove its keys.
- InputCapture.system requires the resource's value type to be exactly
  the source's snapshot type, not merely assignable both ways, and the
  error names SnapshotOf as the fix.
- Gamepad.actions takes the window and reads the pads on every animation
  frame until dispose, so poll() is gone and the lifecycle matches
  Keyboard (snapshot / rebind / dispose).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Gamepad timeline entries take an optional `connected`: a disconnect
  releases every held button and zeroes the sticks and triggers, as with
  a live pad, and recordings write disconnects and reconnects, so a
  replay no longer stays connected after the pad drops.
- The top-down integration reads a gamepad next to the keyboard: a
  GamepadInput service, a PadState resource declared with
  InputCapture.SnapshotOf, its own capture system, the left stick at its
  analog length or the d-pad for walking, and south to interact, with
  headless tests for each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@SandroMaglione
SandroMaglione merged commit f8383bf into main Oct 22, 2026
2 checks passed
@SandroMaglione
SandroMaglione deleted the claude/gamepad-actions branch October 22, 2026 10:34
SandroMaglione pushed a commit that referenced this pull request Oct 22, 2026
Gamepad, Keyboard.parseRebinding, and the top-down integration's gamepad
landed on main after this branch was cut, still reading { ok, value }.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwwnHMKTTiVBpH9SivGeLk
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants