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_right”) and bind keys to it once; your gameplay reads the action. Rebinding keys or adding a gamepad later never touches the movement code.

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

Step 1: Declare the actions on the root node

Put an input_actions dictionary at class scope on your root node. Each action name maps to a list of bindings (here, two keys each):

class MovementDemo(Node2D):
    input_actions = {
        "move_left":  [Key.A, Key.LEFT],
        "move_right": [Key.D, Key.RIGHT],
        "move_up":    [Key.W, Key.UP],
        "move_down":  [Key.S, Key.DOWN],
    }

Why the class attribute, not main()? SimVX registers input_actions when the scene starts, before the first frame. Registering actions inside main() works on the desktop but is silently skipped by the web export (which never runs main()), so your web build would have no input. The class attribute is the canonical path. Registering from a node callback such as on_ready() works everywhere too, because those callbacks run on every backend; only code outside the scene’s lifecycle (main(), module scope after App.run() returns) is skipped on the web.

Step 2: Read the actions and move

Input.get_strength(action) returns how hard an action is held, from 0 to 1. It reads identically for a key (0 or 1) or a gamepad axis (anything between), so the same line of code handles both. Subtract opposite directions to get a signed axis:

class Player(Node2D):
    SPEED = 360.0

    def on_update(self, dt):
        dx = Input.get_strength("move_right") - Input.get_strength("move_left")
        dy = Input.get_strength("move_down")  - Input.get_strength("move_up")
        self.position += Vec2(dx, dy) * self.SPEED * dt

Step 3: Diagonals are faster, so normalise

That code has the classic movement bug. Hold right and down together and the vector is (1, 1), whose length is about 1.41: the square travels 41% faster diagonally than it does along an axis. Capping the direction at length 1 fixes it, and Input.get_vector does exactly that for the four actions at once:

direction = Input.get_vector("move_left", "move_right", "move_up", "move_down")
self.position += direction * self.SPEED * dt

get_vector is the same subtraction as Step 2 on both axes, plus the normalise. Prefer it whenever you read a direction from four actions.

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:

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

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 (a touch reports as the left mouse button):

def _pointer_direction(self) -> Vec2:
    if not Input.is_mouse_button_pressed(MouseButton.LEFT):
        return Vec2(0, 0)
    offset = Input.mouse_position - self.position
    return offset.normalized() if offset.length() > self.POINTER_DEAD_ZONE else Vec2(0, 0)

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

Run it

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

# From the root of a repository checkout
uv run python examples/tutorials/input_and_movement/main.py

What’s next

  • Bouncing BallsProperty descriptors and spawning many children.

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

Source

  1"""Input and Movement: Drive a node from the keyboard with input actions.
  2
  3The third lesson: turn key presses into movement. Instead of checking raw keys,
  4SimVX uses named *input actions* ("move_left") that you bind to one or more keys.
  5Your game logic reads the action, so rebinding keys (or adding a gamepad) never
  6touches gameplay code.
  7
  8Move the square with WASD or the arrow keys, or hold the left mouse button (a
  9finger on a touch screen counts as the left button) to steer it to the pointer.
 10
 11# /// simvx
 12# tags = ["tutorial", "beginner", "input"]
 13# web = { root = "MovementDemo", width = 800, height = 600, responsive = true }
 14# ///
 15
 16## What you will learn
 17
 18- **Input actions** -- name an intent ("move_right") and bind keys to it.
 19- **`input_actions` class attribute** -- the canonical way to register actions on the
 20  root node (registering in `main()` is skipped by the web export, so actions there
 21  are silently lost).
 22- **`Input.get_strength(action)`** -- read how hard an action is held (0..1), which
 23  reads the same for a key or a gamepad axis.
 24- **`Input.get_vector(...)`** -- read four actions as one direction, already normalised
 25  so a diagonal is not faster than a straight line.
 26- **Frame-rate-independent movement** -- multiply speed by `dt` so motion is the same
 27  on any machine.
 28- **One movement path, several devices** -- the pointer fallback feeds the same two
 29  lines of movement code, which never learns where the input came from.
 30
 31## How it works
 32
 33`MovementDemo` (the root) declares `input_actions` at class scope, binding each
 34direction to two keys. The `Player` child turns those four actions into a direction
 35vector every frame, falls back to steering towards a held pointer when no direction
 36key is down, and moves by `direction * speed * dt`, kept inside the window with
 37`Vec2.clamped`. No raw key codes appear in the movement code.
 38
 39Run: uv run python examples/tutorials/input_and_movement/main.py
 40Headless self-check: uv run python examples/tutorials/input_and_movement/main.py --test
 41"""
 42
 43from simvx.core import Input, Key, MouseButton, Node2D, Vec2
 44from simvx.graphics import App
 45
 46WIDTH, HEIGHT = 800, 600
 47
 48
 49class Player(Node2D):
 50    """Reads the named actions and moves. Knows nothing about which keys are bound."""
 51
 52    SIZE = 56
 53    SPEED = 360.0
 54    POINTER_DEAD_ZONE = 4.0  # stop when the pointer is this close, so we do not jitter
 55
 56    def on_update(self, dt: float):
 57        # get_vector reads the four actions, subtracts each opposing pair, and clamps
 58        # the result to length 1, so holding right+down is not faster than holding
 59        # right alone. One axis written out by hand is:
 60        #     Input.get_strength("move_right") - Input.get_strength("move_left")
 61        # and get_strength reads the same for a key (0 or 1) or a gamepad stick (0..1).
 62        direction = Input.get_vector("move_left", "move_right", "move_up", "move_down")
 63        if direction.length() == 0.0:
 64            direction = self._pointer_direction()
 65
 66        half = self.SIZE / 2
 67        moved = self.position + direction * self.SPEED * dt
 68        self.position = moved.clamped(Vec2(half, half), Vec2(WIDTH - half, HEIGHT - half))
 69
 70    def _pointer_direction(self) -> Vec2:
 71        """Steer towards a held pointer, so the browser build works without a keyboard.
 72
 73        A touch reports as the left mouse button, so this covers phones and tablets too.
 74        """
 75        if not Input.is_mouse_button_pressed(MouseButton.LEFT):
 76            return Vec2(0, 0)
 77        offset = Input.mouse_position - self.position
 78        return offset.normalized() if offset.length() > self.POINTER_DEAD_ZONE else Vec2(0, 0)
 79
 80    def on_draw(self, renderer):
 81        half = self.SIZE / 2
 82        top_left = (self.position.x - half, self.position.y - half)
 83        renderer.draw_rect(top_left, (self.SIZE, self.SIZE), colour=(0.4, 0.8, 1.0, 1.0), filled=True)
 84
 85
 86class MovementDemo(Node2D):
 87    """Root node: declares the input actions and spawns the player."""
 88
 89    # The canonical, web-safe registration path. SimVX reads this when the scene
 90    # starts, before the first frame. Each action maps to a list of bindings.
 91    input_actions = {
 92        "move_left": [Key.A, Key.LEFT],
 93        "move_right": [Key.D, Key.RIGHT],
 94        "move_up": [Key.W, Key.UP],
 95        "move_down": [Key.S, Key.DOWN],
 96    }
 97
 98    def on_ready(self):
 99        self.player = self.add_child(Player(position=Vec2(WIDTH / 2, HEIGHT / 2)))
100
101    def on_draw(self, renderer):
102        renderer.draw_text("Input and Movement", (20, 20), scale=2, colour=(1, 1, 1))
103        renderer.draw_text("WASD or arrow keys to move, or hold the pointer to steer", (20, 52), colour=(0.7, 0.7, 0.7))
104
105
106def _selftest() -> bool:
107    """Headless: drive the player with each device the lesson claims works.
108
109    Both keys bound to an action are pressed in turn, so the "one intent, several
110    keys" claim is measured rather than asserted; a diagonal is compared against a
111    straight line to check the direction really is normalised; and the pointer is
112    held down to check it feeds the same two lines of movement code. Every input
113    goes through ``InputSimulator``, so the action map is part of what is tested.
114    """
115    from simvx.core.testing import InputSimulator
116    from simvx.graphics.testing import assert_not_blank, save_png
117
118    HELD = 30  # half a second of holding, long enough to measure but short of any wall
119    RUNS = (
120        ("D", (Key.D,), Vec2(1, 0)),
121        ("the Right arrow, bound to the same action", (Key.RIGHT,), Vec2(1, 0)),
122        ("W", (Key.W,), Vec2(0, -1)),
123        ("D and S together", (Key.D, Key.S), Vec2(1, 1).normalized()),
124    )
125    CORNER = (WIDTH - 4.0, HEIGHT - 4.0)  # a pointer held past the far corner
126
127    app = App(title="Input and Movement", width=WIDTH, height=HEIGHT, visible=False)
128    scene = MovementDemo(name="MovementDemo")
129    sim = InputSimulator()
130    seen: dict[str, object] = {}
131    moves: list[tuple[str, Vec2, Vec2]] = []  # (label, measured travel, expected direction)
132
133    def place(x: float, y: float) -> None:
134        scene.player.position = Vec2(x, y)
135
136    def on_frame(idx: int, _t: float) -> bool:
137        step = idx // (HELD + 4)
138        phase = idx % (HELD + 4)
139        if step < len(RUNS):
140            label, keys, direction = RUNS[step]
141            if phase == 0:
142                place(WIDTH / 2, HEIGHT / 2)
143                seen["from"] = Vec2(scene.player.position)
144            elif phase == 1:
145                for key in keys:
146                    sim.press_key(key)
147            elif phase == 1 + HELD:
148                for key in keys:
149                    sim.release_key(key)
150                moves.append((label, Vec2(scene.player.position) - seen["from"], direction))
151        elif step == len(RUNS):
152            if phase == 0:
153                place(WIDTH / 2, HEIGHT / 2)
154                seen["pointer_from"] = Vec2(scene.player.position)
155            elif phase == 1:
156                sim.press_mouse(MouseButton.LEFT, CORNER)
157            elif phase == 1 + HELD:
158                seen["pointer_to"] = Vec2(scene.player.position)
159            elif phase == 2 + HELD:
160                sim.release_mouse(MouseButton.LEFT)
161        elif step == len(RUNS) + 1:
162            if phase == 0:
163                place(WIDTH - 10.0, HEIGHT - 10.0)
164            elif phase == 1:
165                sim.press_key(Key.D)
166                sim.press_key(Key.S)
167            elif phase == 1 + HELD:
168                sim.release_key(Key.D)
169                sim.release_key(Key.S)
170                seen["corner"] = Vec2(scene.player.position)
171        return True
172
173    total = (HELD + 4) * (len(RUNS) + 2)
174    frames = app.run_headless(scene, frames=total, on_frame=on_frame, capture_frames=[total - 1])
175    assert_not_blank(frames[0])
176    save_png(frames[0], "/tmp/input_and_movement_test.png")
177
178    ok = True
179
180    def check(label: str, passed: bool, detail: str) -> None:
181        nonlocal ok
182        ok = ok and passed
183        print(f"{'ok  ' if passed else 'FAIL'} {label}: {detail}")
184
185    expected = Player.SPEED * HELD / 60.0
186    for label, travel, direction in moves:
187        want = direction * expected
188        check(
189            f"holding {label} moves the player {expected:.0f}px that way",
190            (travel - want).length() < 1.0,
191            f"({travel.x:.1f}, {travel.y:.1f}) against ({want.x:.1f}, {want.y:.1f})",
192        )
193    diagonal = next(t for label, t, _ in moves if "together" in label)
194    straight = next(t for label, t, _ in moves if label == "D")
195    check(
196        "so a diagonal is no faster than a straight line: get_vector normalises it",
197        abs(diagonal.length() - straight.length()) < 1.0,
198        f"{diagonal.length():.1f}px diagonally against {straight.length():.1f}px straight",
199    )
200
201    towards = (seen["pointer_to"] - seen["pointer_from"]).normalized()
202    aim = (Vec2(*CORNER) - seen["pointer_from"]).normalized()
203    check(
204        "holding the pointer steers the player towards it, through the same movement code",
205        (seen["pointer_to"] - seen["pointer_from"]).length() > expected - 1.0 and (towards - aim).length() < 0.02,
206        f"moved {(seen['pointer_to'] - seen['pointer_from']).length():.1f}px " f"towards ({aim.x:.2f}, {aim.y:.2f})",
207    )
208
209    half = Player.SIZE / 2
210    check(
211        "and it stops at the window edge rather than leaving it",
212        abs(seen["corner"].x - (WIDTH - half)) < 0.01 and abs(seen["corner"].y - (HEIGHT - half)) < 0.01,
213        f"came to rest at ({seen['corner'].x:.1f}, {seen['corner'].y:.1f}) on an {WIDTH}x{HEIGHT} window",
214    )
215
216    print("screenshot: /tmp/input_and_movement_test.png")
217    print("SELFTEST:", "PASS" if ok else "FAIL")
218    return ok
219
220
221if __name__ == "__main__":
222    import sys
223
224    if "--test" in sys.argv:
225        sys.exit(0 if _selftest() else 1)
226    App(title="Input and Movement", width=WIDTH, height=HEIGHT).run(MovementDemo())