Skip to content

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.

Kotlin
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:

Kotlin
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 }
        }
    }
}
Kotlin
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:

Kotlin
val game = app {
    scene("harbor-town") {
        frameSystem("walk") {
            WalkSystem { GameplayInput(requireService(Input::class).currentSnapshot, uiOwnership) }
        }
    }
}
Kotlin
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:

controls.scene.json
{ "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():

Kotlin
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:

Kotlin
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:

Kotlin
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:

Kotlin
// 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))
controls.json
{ "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 wheel with ctrlKey, and the page does not zoom. It arrives twice: as Ctrl held with a scroll, as it always has, and as pinch. That frame's scrollDeltaY is the pinch's zoomDelta. Safari's gesture events arrive as pinch only.
  • macOS desktop: the trackpad's magnify gesture arrives as pinch only, 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