Input and Movement¶
Drive a node from the keyboard with input actions.
▶ Run in browserTags: 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 registersinput_actionswhen the scene starts, before the first frame. Registering actions insidemain()works on the desktop but is silently skipped by the web export (which never runsmain()), so your web build would have no input. The class attribute is the canonical path. Registering from a node callback such ason_ready()works everywhere too, because those callbacks run on every backend; only code outside the scene’s lifecycle (main(), module scope afterApp.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 Balls –
Propertydescriptors 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())