Input

Table of Contents

Downstroke's input system is action-based. Your game never asks "is the W key held?" — it asks "is the up action held?". A single input-config record decides which raw SDL events (keyboard keys, joystick buttons, controller buttons, analog axes) map to which abstract action symbols, so the same update loop works on keyboard, joystick, and game controller without changes.

Each frame, the engine collects pending SDL events, folds them into a fresh input-state record (current + previous action alists), and stores it on the game struct. Your update: hook reads that state through game-input.

1. The minimum you need

(import (downstroke engine)
        (downstroke input)
        (downstroke world)
        (downstroke entity))

(game-run!
 (make-game
  title: "Move a Square" width: 320 height: 240

  create: (lambda (game)
            (game-scene-set! game
              (make-sprite-scene
               entities: (list (plist->alist
                                (list #:type 'player
                                      #:x 150 #:y 100
                                      #:width 32 #:height 32
                                      #:color '(100 160 255)))))))

  update: (lambda (game dt)
            (let* ((scene  (game-scene game))
                   (input  (game-input game))
                   (player (car (scene-entities scene)))
                   (dx (cond ((input-held? input 'left)  -2)
                             ((input-held? input 'right)  2)
                             (else 0))))
              (game-scene-set! game
                (update-scene scene
                  entities: (list (entity-set player #:x
                                    (+ (entity-ref player #:x 0) dx))))))) ))

No input-config: keyword is passed, so *default-input-config* is used: arrow keys, WASD, j=/=z for a, k=/=x for b, return for start, escape for quit, plus a standard game-controller mapping.

2. Core concepts

2.1. Actions vs raw keys

An action is a symbol that names something the game cares about (up, a, start). A key binding (or button/axis binding) maps a raw hardware event to an action. Game code only ever reads actions — raw SDL keysyms never leak into your update: hook.

The default action list, defined in *default-input-config*, is:

'(up down left right a b start select quit)

These map loosely to a generic two-button gamepad: a D-pad (up, down, left, right), two face buttons (a, b), two system buttons (start, select), and a synthetic quit action the engine uses to terminate game-run! (the default loop exits when quit is held).

You are free to add or rename actions when you build a custom input-config — the action list is just the set of symbols your game agrees to recognise.

2.2. The default input config

*default-input-config* is a input-config record bound at module load time in input.scm. Its contents:

Keyboard map (SDL keysym → action):

Keys Action
w, up up
s, down down
a, left left
d, right right
j, z a
k, x b
return start
escape quit

Joystick button map (SDL joy button id → action). These ids suit a generic USB pad in the "SNES-ish" layout:

Button id Action
0 a
1 b
7 start
6 select

Game-controller button map (SDL SDL_GameController symbol → action). This is the "Xbox-style" API SDL2 exposes for known controllers:

Button Action
a a
b b
start start
back select
dpad-up up
dpad-down down
dpad-left left
dpad-right right

Analog axis bindings. Each binding is (axis positive-action negative-action) — when the axis value exceeds the deadzone, the positive action is held; when it drops below the negated deadzone, the negative action is held.

  • Joystick: (0 right left) and (1 down up) (X and Y axes).
  • Controller: (left-x right left) and (left-y down up) (left analog stick).

Deadzone is 8000 (SDL axis values range −32768 to 32767).

All of these are accessible via input-config-keyboard-map, input-config-joy-button-map, and so on, but you generally won't touch them directly — you pass a whole replacement record via make-input-config (see below).

2.3. Querying input each frame

The engine calls input-state-update once per frame with the SDL events it has just collected. That produces a new input-state record and stores it on the game via game-input-set!. Your update: hook reads it with (game-input game):

(update: (lambda (game dt)
           (let ((input (game-input game)))
             ...)))

Three predicates live on an input-state:

  • (input-held? state action)#t while the action is currently active (key/button down, axis past deadzone).
  • (input-pressed? state action)#t for exactly one frame: the frame on which the action transitioned from not-held to held.
  • (input-released? state action)#t for exactly one frame: the frame on which the action transitioned from held to not-held.

Press / release are derived from the record itself: input-state carries both a current and previous alist of (action . bool) pairs, and input-state-update rolls the previous snapshot forward each frame. You do not need to maintain any input history yourself.

A fourth convenience, (input-any-pressed? state config), returns #t if any action in the config's action list transitioned to pressed this frame — useful for "press any key to continue" screens.

The call shape throughout the demos is:

(input-held?     (game-input game) 'left)
(input-pressed?  (game-input game) 'a)
(input-released? (game-input game) 'start)

2.4. Customising the input config

Build a replacement config with make-input-config and pass it to make-game via the input-config: keyword:

(define my-input
  (make-input-config
    actions: '(up down left right fire pause quit)
    keyboard-map: '((up    . up)     (w . up)
                    (down  . down)   (s . down)
                    (left  . left)   (a . left)
                    (right . right)  (d . right)
                    (space . fire)
                    (p     . pause)
                    (escape . quit))
    joy-button-map:          '()
    controller-button-map:   '()
    joy-axis-bindings:       '()
    controller-axis-bindings: '()
    deadzone: 8000))

(make-game
  title: "Custom Controls"
  input-config: my-input
  ...)

All seven slots are required, but any of the map/binding slots can be empty ('()) if you don't want that input type. The actions list determines which symbols input-any-pressed? sweeps, and seeds the initial input-state with an entry per action.

If you want to extend the defaults instead of replacing them, pull each slot off *default-input-config* and cons your extra entries:

(define my-input
  (make-input-config
    actions: (input-config-actions *default-input-config*)
    keyboard-map: (cons '(space . a)
                        (input-config-keyboard-map *default-input-config*))
    joy-button-map: (input-config-joy-button-map *default-input-config*)
    controller-button-map: (input-config-controller-button-map *default-input-config*)
    joy-axis-bindings: (input-config-joy-axis-bindings *default-input-config*)
    controller-axis-bindings: (input-config-controller-axis-bindings *default-input-config*)
    deadzone: (input-config-deadzone *default-input-config*)))

3. Common patterns

3.1. Arrow-key movement

Straight from demo/getting-started.scm: test each horizontal and vertical direction independently, pick a signed step, and write back the updated position.

(define +speed+ 2)

(define (move-player player input)
  (let* ((x  (entity-ref player #:x 0))
         (y  (entity-ref player #:y 0))
         (dx (cond ((input-held? input 'left)  (- +speed+))
                   ((input-held? input 'right)    +speed+)
                   (else 0)))
         (dy (cond ((input-held? input 'up)    (- +speed+))
                   ((input-held? input 'down)     +speed+)
                   (else 0))))
    (entity-set (entity-set player #:x (+ x dx)) #:y (+ y dy))))

3.2. Jump on press vs move while held

The platformer distinguishes a continuous action (running left/right while left=/=right are held) from an edge-triggered action (jumping exactly once when a is first pressed):

(define (player-vx input)
  (cond ((input-held? input 'left)  -3)
        ((input-held? input 'right)  3)
        (else 0)))

(define (update-player player input)
  (let* ((jump? (and (input-pressed? input 'a)
                     (entity-ref player #:on-ground? #f)))
         (player (entity-set player #:vx (player-vx input))))
    (if jump?
        (entity-set player #:ay (- *jump-force*))
        player)))

Using input-pressed? instead of input-held? prevents the player from "spamming" the jump simply by keeping a depressed — the action must be released and re-pressed to fire again.

The same pattern shows up in demo/shmup.scm for firing bullets:

(if (input-pressed? input 'a)
    (values updated (list (make-bullet ...)))
    (values updated '()))

3.3. Controller + keyboard simultaneously

The default config registers bindings for keyboard, joystick, and game controller at the same time — no configuration switch, no "input device" concept. Whichever device fires an event first on a given frame sets the corresponding action, and the next frame reflects it. You get controller support for free as long as you keep the default config (or carry its joy-* / controller-* slots forward into your custom config).

game-run! opens every connected game controller at startup, and handle-controller-device opens any controller hot-plugged during the session, so no extra wiring is needed.

3.4. Remapping a single action

To let players press space for start instead of return, override just the keyboard map (keeping the rest of the defaults):

(define my-input
  (make-input-config
    actions: (input-config-actions *default-input-config*)
    keyboard-map:
      '((w . up) (up . up)
        (s . down) (down . down)
        (a . left) (left . left)
        (d . right) (right . right)
        (j . a) (z . a)
        (k . b) (x . b)
        (space . start)         ;; was (return . start)
        (escape . quit))
    joy-button-map:          (input-config-joy-button-map *default-input-config*)
    controller-button-map:   (input-config-controller-button-map *default-input-config*)
    joy-axis-bindings:       (input-config-joy-axis-bindings *default-input-config*)
    controller-axis-bindings: (input-config-controller-axis-bindings *default-input-config*)
    deadzone:                (input-config-deadzone *default-input-config*)))

(make-game
  title: "Remapped Start"
  input-config: my-input
  ...)

Multiple keys can map to the same action (the defaults already do this: both w and up trigger up), so another approach is to add (space . start) alongside the existing (return . start) entry instead of replacing it.

4. Demos

  • Getting started (bin/demo-getting-started) — arrow-key movement with input-held?.
  • Platformer (bin/demo-platformer) — mixes input-held? for running with input-pressed? for jumping.
  • Shmup (bin/demo-shmup) — uses input-pressed? on a to fire bullets exactly once per button press.

5. See also

  • Getting-started guide — the full walkthrough that builds the minimal input example above.
  • Entities — the #:input-map entity key and apply-input-to-entity hook for data-driven movement.
  • Physics — how velocities set by input feed into the built-in physics pipeline.
  • Scenes — how game-input fits alongside game-scene in the update: hook.

Author: Gene Pasquet

Created: 2026-04-20 Mon 15:22

Validate