mcpField
A
actions

actions

github.com/drawcall-aiDevelopment120 installs
Repository

About actions

actions is an input-layer skill for developers building games with @pmndrs/viverse. It maps keyboard, mouse, touch, and on-screen controls to Actions consumed by game logic, with separate state and event types and lifecycle guidance for game modes.

Key features

  1. 01Separates StateAction and EventAction
  2. 02Includes keyboard, mouse, and touch bindings
  3. 03Provides screen joystick and jump button bindings
  4. 04Supports custom Actions and input mappings
  5. 05Manages binding lifecycles with AbortController

Use cases

  1. 01Setting up keyboard or touch movement for a game character
  2. 02Sending one-shot inputs such as jump or fire to systems
  3. 03Scoping controls to menu, gameplay, or cutscene modes

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/drawcall-ai/skills --skill actions

After installing, type/actionsin Claude Code to confirm it loaded.

Details

From the project README

Actions and Action Bindings with @pmndrs/viverse

Actions decouple inputs from game logic. Bindings translate hardware events into actions that systems consume each frame.

Input → Binding → Action → Game Logic

This skill covers only the input layer. For a moving character, that intent flows on: input → Acta → physics — your system reads these actions and hands a move intent to Acta (animation), which produces the velocity for BvhCharacterPhysics. Don't drive the physics controller straight from raw input. See the acta, physics, and camera skills.

Action Types

  • StateAction – Persistent state (movement, running). Multiple writers merge into one value via .get().
  • EventAction – One-shot events (jump, fire). Emitted via .emit(), consumed via .subscribe().

Built-in Actions

ActionTypePurpose
MoveForwardAction, MoveBackwardAction, MoveLeftAction, MoveRightActionStateMovement (0–1) — read with .get() each frame
RunActionStateSprint toggle — read with .get() each frame
JumpActionEventJump trigger — use .subscribe()
RotateYawAction, RotatePitchActionEventReady-to-use rotation delta (radians) — use .subscribe() and accumulate
ZoomActionEventCamera zoom delta — use .subscribe()

State vs Event: State actions persist until changed — poll with .get(). Event actions fire once — capture with .subscribe() or values are lost.

Using Actions in Systems

The examples below assume one const abortController = new AbortController() created at setup; its .signal is passed to every binding and subscription so they all clean up together (see Lifecycle at the end).

Poll StateAction with .get() each frame. Subscribe to EventAction once at init — never in the update loop.

class PlayerSystem extends createSystem(query) {
  private isJumping = false

  init() {
    // ✅ Subscribe to events ONCE
    JumpAction.subscribe(
      () => {
        this.isJumping = true
      },
      { signal: abortController.signal },
    )
  }

  update(delta: number) {
    // ✅ Poll state each frame
    const forward = MoveForwardAction.get()

    // ❌ NEVER subscribe here — creates duplicate listeners every frame
  }
}

Built-in Bindings

All built-in bindings take (domElement, abortSignal) and clean up automatically when aborted.

Keyboard
import { KeyboardLocomotionActionBindings } from '@pmndrs/viverse'

const keyboard = new KeyboardLocomotionActionBindings(document.body, abortController.signal)

// Optional: require pointer lock for movement
keyboard.moveForwardBinding.requiresPointerLock = true

// Optional: customize keys (defaults: WASD, Shift, Space)
keyboard.moveForwardBinding.keys = ['KeyW', 'ArrowUp']
Mouse (Pointer Lock)
import { PointerLockRotateZoomActionBindings } from '@pmndrs/viverse'

const mouse = new PointerLockRotateZoomActionBindings(document.body, abortController.signal)
mouse.lockOnClick = true

With lockOnClick, a click anywhere on the target locks the pointer — so this binding must not be live while the player needs the cursor for menus or HUD buttons, or the menu becomes unusable. See Scope controls to the active mode.

Consuming Rotation Deltas

RotateYawAction and RotatePitchAction emit ready-to-use rotation deltas (radians) on mouse move. Subscribe and accumulate them:

import { RotateYawAction, RotatePitchAction } from '@pmndrs/viverse'
import { Euler } from 'three'

let pitch = 0,
  yaw = 0
RotatePitchAction.subscribe(
  (delta) => {
    pitch = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, pitch + delta))
  },
  { signal: abortController.signal },
)
RotateYawAction.subscribe(
  (delta) => {
    yaw += delta
  },
  { signal: abortController.signal },
)

// Apply to camera: positive pitch = look up, positive yaw = look left
camera.rotation.copy(new Euler(pitch, yaw, 0, 'YXZ'))

Convention: Positive pitch rotates up, positive yaw rotates left. Use Euler(pitch, yaw, 0, 'YXZ') order — see the camera skill for camera patterns.

Touch (Pointer Capture)
import { PointerCaptureRotateZoomActionBindings } from '@pmndrs/viverse'

const touch = new PointerCaptureRotateZoomActionBindings(document.body, abortController.signal)
Mobile UI
import { ScreenJoystickLocomotionActionBindings, ScreenButtonJumpActionBindings } from '@pmndrs/viverse'

const joystick = new ScreenJoystickLocomotionActionBindings(document.body, abortController.signal)
const jumpBtn = new ScreenButtonJumpActionBindings(document.body, abortController.signal)

Controls are auto-hidden on desktop via the .mobile-only CSS class.

Custom Key → Action Mapping

Use mapFrom to transform input events before emitting:

import { KeyboardActionBinding, RotateYawAction } from '@pmndrs/viverse'

const mapped = RotateYawAction.mapFrom((e: KeyboardEvent) => {
  if (e.code === 'KeyQ') return -0.02
  if (e.code === 'KeyE') return 0.02
  return 0
})

const binding = new KeyboardActionBinding(mapped, document.body, abortController.signal)
binding.keys = ['KeyQ', 'KeyE']

Creating Custom Actions

import { StateAction, EventAction } from '@pmndrs/viverse'

// StateAction(mergeFn, neutralValue)
const CrouchAction = new StateAction<boolean>((a, b) => a || b, false)

// EventAction(combineFn?, neutralValue?)
const FireAction = new EventAction<void>()

A StateAction must be given a merge function and neutral value — .get() throws without them (the merge fn is what combines multiple writers). An EventAction only needs a combine function if you intend to read it with .get(); for the normal .emit()/.subscribe() pattern, the no-arg form is fine.

Writing to StateAction

Create a writer, then call .write() on state changes. Multiple writers merge automatically.

const crouchWriter = CrouchAction.createWriter(abortController.signal)

document.body.addEventListener(
  'keydown',
  (e) => {
    if (e.code === 'KeyC') crouchWriter.write(true)
  },
  { signal: abortController.signal },
)

document.body.addEventListener(
  'keyup',
  (e) => {
    if (e.code === 'KeyC') crouchWriter.write(false)
  },
  { signal: abortController.signal },
)

// Read merged state
const isCrouching = CrouchAction.get()

Emitting EventAction

Call .emit() when the event occurs. For continuous polling (e.g., gamepad), track previous state to emit only on press:

let wasPressed = false

function pollGamepad() {
  const pressed = navigator.getGamepads()[0]?.buttons[0].pressed ?? false
  if (pressed && !wasPressed) FireAction.emit()
  wasPressed = pressed
  requestAnimationFrame(pollGamepad)
}

// Or subscribe to events
FireAction.subscribe(() => console.log('Fire!'), { signal: abortController.signal })

Lifecycle

Use AbortController to manage cleanup:

const abortController = new AbortController()

// Create bindings...
const keyboard = new KeyboardLocomotionActionBindings(document.body, abortController.signal)

// Clean up all bindings at once
abortController.abort()

Scope controls to the active mode

A game has modes — a menu, active play, a pause screen, a cutscene, a game-over screen — and the controls that are live must match the current mode. The cleanest way to guarantee that is to give each mode its own AbortController (see Lifecycle): entering a mode aborts the previous mode's controller, tearing down all of its bindings and subscriptions at once, and constructs the bindings the new mode needs. Controls that don't exist can't misfire, which is a stronger guarantee than disabling them with a flag.

The recurring failure is leaving gameplay controls live in a mode that doesn't use them. A PointerLockRotateZoomActionBindings with lockOnClick = true constructed at startup and never torn down grabs the pointer the moment the player clicks a menu button, making the menu unusable. The fix is not a special case for that button — it is that the menu mode constructs no gameplay controls at all, so the lock binding exists only once play begins. The same shape makes a cutscene keep responding to WASD and mouse-look when the play mode's bindings were left live; the cutscene mode should own no player controls.

When a single mode genuinely needs both live controls and clickable DOM UI — a HUD button during play — the UI interaction must not reach the listener that locks the pointer. A lock-on-click listener on document.body fires for clicks anywhere, because the event bubbles up to it. Pass the canvas element as the binding's DOM target instead of document.body, so a click on overlay UI outside the canvas never reaches it; or call stopPropagation() in the UI's pointerdown handler. (For clickable UI inside the 3D scene, filtering is per-object — see the pointer-events skill.)

Controls are rarely the only thing a mode owns: a cutscene or pause usually also freezes the simulation (stop and resume the relevant systems — see the ecs skill) and may hand the camera to a different owner (see the camera skill). Switch everything a mode owns together on the transition — the bug is always the one concern left live from the previous mode.

Limitations — filling gaps

The built-in bindings cover keyboard, mouse (pointer-lock), touch, and on-screen mobile controls. When you need something they don't — a gamepad, a remapped key, a game-specific verb (crouch, lean, ability) — that's expected: define a custom StateAction/EventAction and either a mapFrom binding or your own DOM/gamepad listener that writes to it (shown above). The action layer is meant to be extended this way, so reach for a custom action rather than reading raw input inside gameplay systems.

Comments

Markdown supported