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)—#twhile the action is currently active (key/button down, axis past deadzone).(input-pressed? state action)—#tfor exactly one frame: the frame on which the action transitioned from not-held to held.(input-released? state action)—#tfor 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 withinput-held?. - Platformer (
bin/demo-platformer) — mixesinput-held?for running withinput-pressed?for jumping. - Shmup (
bin/demo-shmup) — usesinput-pressed?onato 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-mapentity key andapply-input-to-entityhook for data-driven movement. - Physics — how velocities set by input feed into the built-in physics pipeline.
- Scenes — how
game-inputfits alongsidegame-scenein theupdate:hook.