Input
Read keys, pointer buttons and scroll from a per-frame snapshot, in a scene's update block or in your own systems.
module: com.awakekt.awake.core:input
Desktop · Android · iOS · Web
Every app has one Input. The platform host writes key and pointer events into it as they arrive,
and once per frame takes an InputSnapshot: a fixed picture of that frame that every system reads.
A scene names its actions, and the keys behind them, in its input_actions component; see
Bind actions in the scene. For the player's movement, see
Cameras and controls.
Read a snapshot
isDown is true while a key or button is held. wasPressed is true only on the frame it went down.
val input = Input()
input.setKeyDown(Key.Space, true)
input.setPointer(down = true, x = 120f, y = 80f)
val frame1 = input.updateSnapshot()
frame1.isDown(Key.Space) // true: held
frame1.wasPressed(Key.Space) // true: went down this frame
frame1.isDown(PointerButton.Primary) // true
val frame2 = input.updateSnapshot()
frame2.isDown(Key.Space) // still true
frame2.wasPressed(Key.Space) // false: the edge lasted one frame
A test drives input this way; in an app the host calls updateSnapshot() for you, once per frame.
Read input in a scene
The update block receives the frame's snapshot. A system looks up the Input service and reads
currentSnapshot when it runs:
val game = app {
scene("harbor-town") {
entity("player") { with(Jumper()) }
// Once per fixed step, with the frame's snapshot.
update { step, input ->
if (input.isDown(Key.Space)) updateSawSpace = true
}
// A system reads the session's Input service when it runs.
frameSystem("jump") {
JumpSystem { requireService(Input::class).currentSnapshot }
}
}
}
class Jumper {
var jumps = 0
}
class JumpSystem(private val input: () -> InputSnapshot) : System {
override fun update(world: World, delta: Float) {
if (!input().wasPressed(Key.Space)) return
world.queryEach<Jumper> { _, jumper -> jumper.jumps += 1 }
}
}
The system takes a function rather than a snapshot, so it reads the current frame each time instead of the one it was created with.
Leave the UI its input
When a text field has focus or a dialog is open, gameplay should ignore the keys. GameplayInput
wraps a snapshot and what the UI claimed this frame (uiOwnership on the scene runtime), and
answers false for anything the UI owns:
val game = app {
scene("harbor-town") {
frameSystem("walk") {
WalkSystem { GameplayInput(requireService(Input::class).currentSnapshot, uiOwnership) }
}
}
}
class Walker {
var steps = 0
}
class WalkSystem(private val input: () -> GameplayInput) : System {
override fun update(world: World, delta: Float) {
if (!input().isDown(Key.W)) return // not down while a text field has focus
world.queryEach<Walker> { _, walker -> walker.steps += 1 }
}
}
GameplayInput is in com.awakekt.awake.scene:controls. The built-in camera and player systems
already read input through it.
A focused single-line field lets through the keys it has no use for, such as Up and Down, so a
system reading them from GameplayInput still sees them, for stepping through a chat line's sent
history, say. The scene's input actions read no key at all while a field has focus, so movement
bound to Up doesn't also walk the player.
Bind actions in the scene
A scene's input_actions component names its actions and what triggers each: keys, pointer buttons
and on-screen controls. Systems then ask for an action and never for a key, so a game rebinds its
controls in data:
{ "name": "Controls", "components": [
{ "component": "input_actions", "actions": [
{ "type": "button", "name": "interact", "keys": ["E"], "trigger": "Press" },
{ "type": "button", "name": "run", "keys": ["X"], "trigger": "Toggle", "startsOn": true }
] }
] }
PlayerInputSystem reads the keys into the actions every frame, and a played project's
canvas_element controls add to them. A system of your own reads them from the world with
world.inputActions():
class Interactions {
var count = 0
}
class InteractSystem : System {
override fun update(world: World, delta: Float) {
val actions = world.inputActions() ?: return
if (!actions.wasPressed("interact")) return
world.queryEach<Interactions> { _, interactions -> interactions.count += 1 }
}
}
isActive says whether a button is active as its trigger says, wasPressed whether one was
pressed this frame, and axisX and axisY where an axis points. The player's move, jump and
run are there whether or not the scene binds them: a scene that does not gets W A S D or the
arrows, Space and Shift held. Every field is in the
reference.
Bind actions to keys
Without a scene, make the InputActions yourself (com.awakekt.awake.core:input) and read each
frame's snapshot into them:
val actions = InputActions(
listOf(
AxisAction("move", up = setOf(Key.W, Key.ArrowUp), down = setOf(Key.S), left = setOf(Key.A), right = setOf(Key.D)),
ButtonAction("jump", keys = setOf(Key.Space), trigger = ActionTrigger.Press),
),
)
// Once a frame:
actions.beginFrame()
actions.read(snapshot)
val jump = actions.isActive("jump")
val forward = actions.axisY("move")
An axis is a unit vector, up and right being positive, or zero when no direction key is held. Read a
GameplayInput instead of the snapshot to leave the UI its keys. KeybindingProfile, which did this
before, is deprecated.
Chords and rebinding
A button action can also take chords: a key pressed while modifiers are held, such as Shift+1 or Ctrl+Alt+K, or a controller button pressed while a shoulder or trigger is held:
val actions = InputActions(
listOf(
ButtonAction("slot1", keys = setOf(Key.Digit1)),
ButtonAction(
"slot11",
chords = setOf(
KeyChord(Key.Digit1, setOf(KeyModifier.Shift)),
GamepadChord(GamepadButton.South, setOf(GamepadButton.LeftShoulder)),
),
),
),
)
Chords on the same key resolve across every action, by three rules:
- Modifiers first. A chord fires when its key goes down while its modifiers are held, or go down in the same frame. Pressing 1 and then Shift is 1.
- The most specific chord wins. Shift+1 does not also fire 1. Actions bound to the same chord all fire.
- The winner keeps the key until it is released. Letting go of Shift while 1 stays down ends Shift+1 without starting 1.
A chord's extraModifiers says whether other modifiers held as well stop it: Block, the default,
keeps Ctrl+S from firing for Ctrl+Shift+S; Allow lets it. A plain key in keys allows them, so
1 still fires while Ctrl is held when nothing binds Ctrl+1. An axis key is a plain key too: Ctrl+S
bound to saving takes S from the move axis. Left and right modifiers are the same modifier. For a
controller, the shoulders and triggers are the modifiers.
A scene declares chords on a button action in its input_actions, under chords, in the same
form; see the reference.
A player's controls are data. bindings() takes every action's binding, InputBindings, which
saves and loads with kotlinx.serialization; rebind and rebindAll change them while the game
runs, and actionsBoundTo says what a new chord would clash with:
// A rebinding screen checks what a new chord clashes with, then takes it:
val chord = KeyChord(Key.Digit1, setOf(KeyModifier.Ctrl))
if (actions.actionsBoundTo(chord).isEmpty()) {
actions.rebind("slot11", ButtonBinding(chords = setOf(chord)))
}
// Settings save the player's bindings as data and load them back next time:
val saved = Json.encodeToString(InputBindings.serializer(), actions.bindings())
val skipped = actions.rebindAll(Json.decodeFromString(InputBindings.serializer(), saved))
{ "actions": {
"slot1": { "type": "button", "keys": ["Digit1"] },
"slot11": { "type": "button", "chords": [
{ "type": "key", "key": "Digit1", "modifiers": ["Ctrl"] },
{ "type": "gamepad", "button": "South", "modifiers": ["LeftShoulder"] }
] }
} }
rebindAll skips, and returns, the names a saved file has that no action does, so an old file
still loads. To show a binding, ButtonBinding.describe() gives each way to trigger it as its
modifiers and the control pressed. InputLabels in com.awakekt.awake.core:localization names
them in the player's language and their platform's style: binding(description) gives Ctrl+K or
Strg+K, chord(chord) names one chord, and bindings(action) every way to trigger an action, such
as E / Shift+E, with controller buttons printed by the player's ControllerLayout. See
Name keys and buttons.
Controller buttons are named by position: South, East, West and North for the face buttons,
the shoulders and triggers, the sticks, the d-pad, Select, Start and Home. A host feeds them
with Input.setGamepadButton, and every built-in host does: GLFW's gamepad API on desktop, the
Gamepad API in a browser, GameController on iOS, and a controller's key and joystick events on
Android. One controller is read, and only one the platform maps by position; a browser lists one
only after a button on it is pressed. GameplayInput passes controller buttons to the actions
whatever the UI holds, since the UI reads no controller.
Properties
InputSnapshot:
| Property | Type | What it holds |
|---|---|---|
keysDown |
set of Key |
Keys held this frame. isDown(key) reads it. |
keysPressed, keysReleased |
set of Key |
Keys that went down or up this frame. wasPressed(key) reads the first. |
pointerX, pointerY |
number | Last pointer position the host reported. |
pointerDown |
boolean | Primary button held. |
pointerPressed, pointerReleased |
boolean | A press or release happened this frame, even if both did. |
buttonsDown, buttonsPressed, buttonsReleased |
set of PointerButton |
Primary, Secondary, Middle, Back, Forward. isDown(button), wasPressed(button), wasReleased(button) read them. |
scrollDeltaX, scrollDeltaY |
number | Scroll this frame. |
gamepadButtonsDown, gamepadButtonsPressed, gamepadButtonsReleased |
set of GamepadButton |
Controller buttons, for a host that reads a controller. isDown(button), wasPressed(button), wasReleased(button) read them. |
scrollSource |
Wheel · Trackpad · Unknown |
What scrolled, where the platform can tell. |
pinch |
PinchGesture or null |
A trackpad pinch this frame: scale (above 1 zooms in, steps multiply), zoomDelta (ln(scale)), and the point it zooms about, focusX, focusY. |
typedText, editActions |
string, list | Text typed this frame, and editing keys such as backspace. |
Key covers letters, digits, arrows, F1 to F5, Space, Escape, Tab, Enter, the editing keys, and
Ctrl, Shift, Alt and Meta (left and right are the same key). A key the host does not map
arrives as Key.Unknown.
Trackpad pinch
A trackpad pinch arrives as pinch, a PinchGesture with the frame's scale and the point it
zooms about. Two-finger scrolling stays scroll.
- Browser: Chrome, Edge and Firefox send a pinch as a
wheelwithctrlKey, and the page does not zoom. It arrives twice: as Ctrl held with a scroll, as it always has, and aspinch. That frame'sscrollDeltaYis the pinch'szoomDelta. Safari's gesture events arrive aspinchonly. - macOS desktop: the trackpad's magnify gesture arrives as
pinchonly, about the cursor. - Windows: a touchpad sends a pinch as Ctrl+wheel, which arrives as scroll only.
So read the pinch or the scroll, never both. On a frame with a pinch, zoom by it and ignore that
frame's scroll; on the others, Ctrl+scroll is the fallback. The built-in cameras do this.
GameplayInput.pinch is null while the UI holds the pointer or a dialog is open.
How it works
The host writes events into Input between frames with setKeyDown, setPointer, setButton,
addPinch and the scroll fields. At the start of a frame it calls updateSnapshot(), which works out the pressed
and released edges against the previous snapshot and clears the per-frame values: scroll, pinch, typed
text and edit actions. The snapshot is then passed to the app as frame.input and stays unchanged for the
whole frame.
wasPressed is per frame, not per fixed step
update runs once per fixed step, and every step in a frame sees the same snapshot. On a frame
that runs two steps, wasPressed is true in both; on a frame that runs none, update does not
see the press. Handle one-shot presses in a frame system.
See also
- Cameras and controls for the built-in player and camera controls.
- App lifecycle for fixed steps and frame systems.