Input and Movement

Drive a node from the keyboard with input actions.

▶ Run in browser

Tags: tutorial beginner input

Input and movement

Now make something move when the player presses a key. SimVX does not ask you to check raw key codes scattered through your game. Instead you name an action (“move”) and bind devices to it once; your gameplay reads the action. Rebinding keys, adding a controller or shipping to a phone never touches the movement code.

By the end you will drive a square around the window with WASD, the arrow keys, a controller’s left stick, or by dragging with the mouse or a finger.

Step 1: Declare the actions on the root node

Put an input_context dictionary at class scope on your root node. Each action name maps to a list of sources, and each source produces a whole value of the action on its own. Axis2D.WASD and Axis2D.ARROWS are ready-made four-key directions:

class MovementDemo(Node2D):
    input_context = {
        "move": [Axis2D.WASD, Axis2D.ARROWS, Controller.LEFT_STICK],
        "steer": [Pointer.PRIMARY],
    }

Why the class attribute, not main()? A node’s input_context is in force exactly while the node is in the tree, so the root’s covers the whole scene from before the first frame, and a scene swapped in later brings its own. InputMap belongs to a scene tree, so calling InputMap.add_action in main() before the tree exists raises. The class attribute is the canonical path. add_action from a node callback such as on_ready() works too, since the tree exists by then; entries every scene shares can also go in the project settings ([input] in simvx.toml).

Step 2: An action is typed by its bindings

move’s sources are all vector-shaped, so move is a Vec2 action and Input.value("move") hands you a direction:

class Player(Node2D):
    SPEED = 360.0

    def on_update(self, dt):
        direction = Input.value("move")
        self.position += direction * self.SPEED * dt

Buttons alone would make the action a bool; an Axis1D or a single controller axis would make it a float. You declare what the action is once, rather than assembling it at every call site.

Step 3: Several sources, and no fight between them

Three sources drive move. They never conflict, because the one furthest from rest wins: a hand resting on a stick loses to a key the player is actually pressing, and a key, which is full deflection by nature, loses to a stick pushed further than it. Summing would overshoot the diagonal and last-device-wins would jitter; largest-magnitude is the one rule a player can predict.

It also fixes the classic movement bug for free. Hold right and down on the keyboard and the raw vector is (1, 1), whose length is about 1.41: the square would travel 41% faster diagonally. Axis2D normalises its corners, and its deadzone is radial rather than per axis, so a stick pushed diagonally is not clipped into a square either.

Step 4: Stay frame-rate independent

Multiplying by dt (seconds since the last frame) is what keeps the speed the same on a 60 fps and a 144 fps machine. Never add a fixed amount per frame: the game would run faster on faster hardware.

Step 5: Keep the square on screen

Vec2.clamped bounds both components at once against a minimum and a maximum corner, so there is no need to clamp x and y by hand:

width, height = self.viewport_size
half = self.SIZE / 2
moved = self.position + direction * self.SPEED * dt
self.position = moved.clamped(Vec2(half, half), Vec2(width - half, height - half))

self.viewport_size is the live size of whatever this node draws into, read every frame. A module-level WIDTH, HEIGHT would be right on the machine you wrote it on and wrong in a resized window, on a phone, and in a browser canvas.

Step 6: The same movement code, from another device

Actions decouple gameplay from hardware, and so does anything else that produces a direction. A browser build may be running on a phone with no keyboard, so when no direction key is held the example steers towards a held pointer instead. Pointer.PRIMARY is the main press of any pointer, so the left mouse button, a pen tip and a finger all hold steer:

direction = Input.value("move")
if direction.length() == 0.0 and Input.pressed("steer"):
    offset = Input.mouse_position - self.position
    if offset.length() > self.POINTER_DEAD_ZONE:
        direction = offset.normalized()

The movement lines never change: they consume a direction and do not care whether a key, a controller stick, or a fingertip produced it.

Gate on the action, read the device. The held button is read as the steer action, not through Input.is_mouse_button_pressed. The two disagree exactly where it matters: a tap that a button or an on-screen stick took never reaches the action layer, while the device query stays honest that the button is physically down. Read through an action and the player does not chase the widget they just pressed. Device queries are the escape hatch, for when you genuinely want to know what the hardware is doing.

Run it

# In your own copy of this directory
python main.py

# Anywhere, with the example library installed
simvx examples run tutorials_input_and_movement

What’s next

  • Bouncing Balls – Property descriptors and spawning many children.

  • Pong – put input, signals, and collision together into a full game.

Source

  1# /// simvx
  2# tags = ["tutorial", "beginner", "input"]
  3# web = { width = 800, height = 600, responsive = true }
  4# ///
  5"""Input and Movement: Drive a node from the keyboard with input actions.
  6
  7The third lesson: turn key presses into movement. Instead of checking raw keys, you name an
  8*input action* ("move") and bind devices to it once. Gameplay reads the action, so rebinding
  9keys, adding a controller or shipping to a phone never touches the movement code.
 10
 11Move the square with WASD, the arrow keys or a controller's left stick, or hold the mouse
 12button or a finger down to steer it towards the pointer.
 13
 14## Declare the actions
 15
 16The root node declares `input_context` at class scope. It is in force exactly while the node
 17is in the tree, so the root's covers the whole scene from before the first frame. Each entry
 18in an action's list is one whole source of its value:
 19
 20    input_context = {
 21        "move": [Axis2D.WASD, Axis2D.ARROWS, Controller.LEFT_STICK],
 22        "steer": [Pointer.PRIMARY],
 23    }
 24
 25`Axis2D.WASD` and `Axis2D.ARROWS` are ready-made four-key directions. `Pointer.PRIMARY` is
 26the main press of any pointer: the left mouse button, a pen tip or a finger.
 27
 28## Typed actions
 29
 30An action's type follows from its bindings. Every source of `move` is vector-shaped, so
 31`Input.value("move")` is one `Vec2` however the player holds the game. Buttons alone, as in
 32`steer`, make a `bool` action, read with `Input.pressed("steer")`.
 33
 34## Largest magnitude wins
 35
 36Several sources on one action never fight: the one furthest from rest is the answer, so a
 37hand resting on a stick loses to a pressed key. `Axis2D` also normalises its corners, so
 38holding right and down is no faster than holding right.
 39
 40## Frame-rate-independent movement
 41
 42`Player.on_update` moves by `direction * SPEED * dt`, so the square covers the same distance
 43per second at any frame rate, and `Vec2.clamped` keeps it inside `self.viewport_size`, the
 44live size of what the node draws into.
 45
 46## Gate on the action, read the device
 47
 48When `move` is at rest the player steers towards `Input.mouse_position` while `steer` is
 49held, stopping inside a small dead zone. The press is read as the action rather than through
 50`Input.is_mouse_button_pressed`: a tap that a UI widget took never reaches the action layer,
 51while the device query would still report the button down and send the player chasing the
 52widget.
 53"""
 54
 55from simvx.core import Axis2D, Controller, Input, Node2D, Pointer, Vec2
 56from simvx.graphics import App
 57
 58
 59class Player(Node2D):
 60    """Reads the actions and moves. Knows nothing about which keys are bound."""
 61
 62    SIZE = 56
 63    SPEED = 360.0
 64    POINTER_DEAD_ZONE = 4.0  # stop this close to the pointer rather than jitter around it
 65
 66    def on_update(self, dt: float):
 67        direction = Input.value("move")
 68        if direction.length() == 0.0 and Input.pressed("steer"):
 69            offset = Input.mouse_position - self.position
 70            if offset.length() > self.POINTER_DEAD_ZONE:
 71                direction = offset.normalized()
 72
 73        width, height = self.viewport_size
 74        half = self.SIZE / 2
 75        moved = self.position + direction * self.SPEED * dt
 76        self.position = moved.clamped(Vec2(half, half), Vec2(width - half, height - half))
 77
 78    def on_draw(self, renderer):
 79        half = self.SIZE / 2
 80        renderer.rect((-half, -half), (self.SIZE, self.SIZE), fill=(0.4, 0.8, 1.0, 1.0))
 81
 82
 83class MovementDemo(Node2D):
 84    """Root node: declares the input actions and spawns the player."""
 85
 86    input_context = {
 87        "move": [Axis2D.WASD, Axis2D.ARROWS, Controller.LEFT_STICK],
 88        "steer": [Pointer.PRIMARY],
 89    }
 90
 91    def on_ready(self):
 92        width, height = self.viewport_size
 93        self.player = self.add_child(Player(position=(width / 2, height / 2)))
 94
 95    # Example help overlay: not part of the technique shown.
 96    def on_draw(self, renderer):
 97        renderer.text("Input and Movement", (20, 20), fill=(1, 1, 1), scale=2)
 98        renderer.text("WASD, arrows or a stick to move, or hold the pointer to steer", (20, 52), fill=(0.7, 0.7, 0.7))
 99
100
101if __name__ == "__main__":
102    App(title="Input and Movement", width=800, height=600).run(MovementDemo())