Pong¶
Complete two-player game in ~150 lines.
â–¶ Run in browserTags: tutorial game input-actions signals collision
Pong¶
This is the capstone of the basics track: a complete two-player game in about 150 lines that puts together everything so far. Input actions move the paddles, a signal reports scoring, and manual collision makes the ball bounce. Player 1 uses W/S, player 2 uses the arrow keys, and either paddle also follows a mouse drag or a touch on its half of the screen.
1. The paddle reads named actions¶
Each Paddle is told the names of its two actions and moves on the signed axis
between them, clamped to the window. The same class drives both players: the
bindings differ, the code does not.
class Paddle(Node2D):
speed = Property(400.0, range=(100, 800))
half_h = PADDLE_H // 2
def __init__(self, up_action, down_action, **kwargs):
super().__init__(**kwargs)
self.up_action, self.down_action = up_action, down_action
def on_update(self, dt):
step = self.speed * dt
dy = Input.get_strength(self.down_action) - Input.get_strength(self.up_action)
if dy:
move = dy * step
else:
target = pointer_y(bool(self.position.x < WIDTH / 2))
move = 0.0 if target is None else max(-step, min(step, target - self.position.y))
self.position.y = max(self.half_h, min(HEIGHT - self.half_h, self.position.y + move))
2. Pointer control for mouse and touch¶
When neither key is down, the paddle chases a pointer held on its half of the screen. Touch fingers and the mouse are read through one small helper, so the same build plays on a desktop and on a phone, and two fingers can drive both paddles at once:
def pointer_y(left_half):
pointers = [(x, y) for x, y, _pressure in Input.touches.values()]
if Input.is_mouse_button_pressed(MouseButton.LEFT):
mouse = Input.mouse_position
pointers.append((mouse.x, mouse.y))
return next((y for x, y in pointers if (x < WIDTH / 2) == left_half), None)
The paddle moves toward the pointer at its normal speed rather than snapping
to it, so a drag cannot outrun a keyboard player.
3. The ball moves, bounces, and announces scoring¶
The Ball integrates its velocity, reflects off the top and bottom walls, and
emits a scored signal when it leaves the left or right edge, then resets to
the centre with a fresh random angle. It does not touch the score itself.
class Ball(Node2D):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.scored = Signal()
self.reset()
def on_update(self, dt):
self.position += self.velocity * dt
# reflect off top/bottom ...
if self.position.x < 0:
self.scored.emit("right"); self.reset()
elif self.position.x > WIDTH:
self.scored.emit("left"); self.reset()
4. The root wires it together¶
PongGame declares its input_actions at class scope (the web-safe registration
path you saw in Input and Movement), builds the two paddles and the ball in
on_ready(), and connects the ball’s scored signal to a handler that bumps the
score. The ball and the score never reference each other.
class PongGame(Node2D):
input_actions = {
"p1_up": [Key.W], "p1_down": [Key.S],
"p2_up": [Key.UP], "p2_down": [Key.DOWN],
}
def on_ready(self):
self.left_paddle = self.add_child(Paddle("p1_up", "p1_down", position=Vec2(30, HEIGHT/2)))
self.right_paddle = self.add_child(Paddle("p2_up", "p2_down", position=Vec2(WIDTH-30, HEIGHT/2)))
self.ball = self.add_child(Ball())
self.scores = [0, 0]
self.ball.scored.connect(self._on_scored)
5. Collision lives in the parent¶
The root checks paddle-ball overlap each frame with a simple AABB test. On a hit it reflects the ball, and varies the bounce angle by where the ball struck the paddle so players can aim:
def on_update(self, dt):
for paddle in (self.left_paddle, self.right_paddle):
if abs(self.ball.position.x - paddle.position.x) < PADDLE_W/2 + BALL_R and \
abs(self.ball.position.y - paddle.position.y) < PADDLE_H/2 + BALL_R:
offset = (self.ball.position.y - paddle.position.y) / (PADDLE_H/2)
angle = offset * math.pi/3
# ... set ball.velocity from angle + a small speed-up
Each hit speeds the ball up 5%, capped at MAX_RALLY_SPEED. The cap is not
cosmetic: a collision tested once per frame only sees the ball while it overlaps
the paddle, so once the per-frame step grows past the paddle’s collision band the
ball would pass straight through it.
For a physics-driven game, use
CharacterBody2D+CollisionShape2Dandmove_and_slide(dt)instead of hand-rolled AABB. Manual collision keeps this tutorial’s moving parts visible.
6. Draw the board¶
on_draw paints the centre line, both scores, and the control hints. Because the
scores are plain state updated by the signal handler, drawing them is just reading
self.scores. The left score and the right-hand hint pass alignment="right",
which anchors the line’s right edge on the given x, so the layout holds when a
score reaches two or three digits.
Run it¶
# In your own copy of this directory
python main.py
# From the root of a repository checkout
uv run python examples/tutorials/pong/main.py
What’s next¶
Monolith to Composed: refactor a single big node into clean, reusable nodes.
Browse the feature references for cameras, tilemaps, particles, audio, and more.
Source¶
1"""Pong: Complete two-player game in ~150 lines.
2
3A classic Pong demonstrating input actions, signals, collision detection,
4and game-state management. Player 1 uses W/S, player 2 uses Up/Down arrows,
5and either paddle also follows a mouse drag or a touch on its half of the screen.
6
7# /// simvx
8# tags = ["tutorial", "game", "input-actions", "signals", "collision"]
9# web = { root = "PongGame", width = 800, height = 600, responsive = true }
10# ///
11
12## What you will learn
13
14- **Input actions**: Bind keys to named actions with the `input_actions` class attribute.
15- **Input.get_strength()**: Read analogue input strength for smooth movement.
16- **Pointer input**: Read touches and the mouse together so the same code plays
17 on a desktop and on a touch screen.
18- **Signals**: Decouple game events (the ball emits `scored` when it passes a paddle).
19- **Collision**: Manual AABB overlap for paddle-ball bouncing.
20- **Game state**: Track and display scores.
21
22## How it works
23
24Three node types compose the game:
25
26- `Paddle` reads two input actions (up/down), falls back to a pointer held on its
27 half of the screen, and clamps position to the screen.
28- `Ball` moves at a velocity, bounces off top/bottom edges, and emits a
29 `scored` signal when it exits left or right.
30- `PongGame` (root) declares `input_actions = {...}` at class scope, creates
31 paddles and ball in `on_ready()`, connects the `scored` signal to update
32 the score, and handles paddle-ball collision in `on_update()` by
33 reflecting the ball's velocity based on where it hits the paddle.
34
35The `input_actions` class attribute is the canonical registration path: the
36scene tree consumes it at mount and re-applies on every `change_scene` swap.
37It also works correctly under the web exporter, which instantiates the root
38class directly without invoking `main()`.
39
40Run: uv run python examples/tutorials/pong/main.py
41Headless self-check: uv run python examples/tutorials/pong/main.py --test
42"""
43
44import math
45import random
46
47from simvx.core import Input, Key, MouseButton, Node2D, Property, Signal, Vec2
48from simvx.graphics import App
49
50WIDTH, HEIGHT = 800, 600
51PADDLE_W, PADDLE_H = 12, 80
52BALL_R = 8
53# A rally speeds the ball up 5% per hit. Cap it: once the per-frame step exceeds
54# the paddle's collision band the ball would pass straight through it.
55MAX_RALLY_SPEED = 900.0
56
57
58def pointer_y(left_half: bool) -> float | None:
59 """Y of the first pointer held on one half of the screen, or None if there is none.
60
61 Touch fingers and the held mouse button are read together, so one code path
62 covers desktop and mobile and two fingers can drive both paddles at once.
63 """
64 pointers = [(x, y) for x, y, _pressure in Input.touches.values()]
65 if Input.is_mouse_button_pressed(MouseButton.LEFT):
66 mouse = Input.mouse_position
67 pointers.append((mouse.x, mouse.y))
68 return next((y for x, y in pointers if (x < WIDTH / 2) == left_half), None)
69
70
71class Paddle(Node2D):
72 speed = Property(400.0, range=(100, 800))
73 half_h = PADDLE_H // 2
74
75 def __init__(self, up_action: str, down_action: str, **kwargs):
76 super().__init__(**kwargs)
77 self.up_action = up_action
78 self.down_action = down_action
79
80 def on_update(self, dt: float):
81 step = self.speed * dt
82 dy = Input.get_strength(self.down_action) - Input.get_strength(self.up_action)
83 if dy:
84 move = dy * step
85 else:
86 target = pointer_y(bool(self.position.x < WIDTH / 2))
87 move = 0.0 if target is None else max(-step, min(step, target - self.position.y))
88 self.position.y = max(self.half_h, min(HEIGHT - self.half_h, self.position.y + move))
89
90 def on_draw(self, renderer):
91 x, y = self.position.x - PADDLE_W // 2, self.position.y - self.half_h
92 renderer.draw_rect((x, y), (PADDLE_W, PADDLE_H), colour=(1.0, 1.0, 1.0, 1.0), filled=True)
93
94
95class Ball(Node2D):
96 speed = Property(350.0, range=(200, 600))
97
98 def __init__(self, **kwargs):
99 super().__init__(**kwargs)
100 self.velocity = Vec2()
101 self.scored = Signal()
102 self.reset()
103
104 def reset(self):
105 self.position = Vec2(WIDTH / 2, HEIGHT / 2)
106 angle = random.choice([-1, 1]) * random.uniform(-math.pi / 4, math.pi / 4)
107 direction = random.choice([-1, 1])
108 self.velocity = Vec2(math.cos(angle) * direction, math.sin(angle)) * self.speed
109
110 def on_update(self, dt: float):
111 self.position += self.velocity * dt
112 if self.position.y < BALL_R:
113 self.position.y = BALL_R
114 self.velocity.y = abs(self.velocity.y)
115 elif self.position.y > HEIGHT - BALL_R:
116 self.position.y = HEIGHT - BALL_R
117 self.velocity.y = -abs(self.velocity.y)
118 if self.position.x < 0:
119 self.scored.emit("right")
120 self.reset()
121 elif self.position.x > WIDTH:
122 self.scored.emit("left")
123 self.reset()
124
125 def on_draw(self, renderer):
126 renderer.draw_circle(self.position, BALL_R, colour=(1.0, 1.0, 1.0, 1.0), filled=True)
127
128
129class PongGame(Node2D):
130 input_actions = {
131 "p1_up": [Key.W],
132 "p1_down": [Key.S],
133 "p2_up": [Key.UP],
134 "p2_down": [Key.DOWN],
135 }
136
137 def on_ready(self):
138 self.left_paddle = self.add_child(Paddle("p1_up", "p1_down", name="Left", position=Vec2(30, HEIGHT / 2)))
139 self.right_paddle = self.add_child(
140 Paddle("p2_up", "p2_down", name="Right", position=Vec2(WIDTH - 30, HEIGHT / 2))
141 )
142 self.ball = self.add_child(Ball(name="Ball"))
143 self.scores = [0, 0]
144 self.ball.scored.connect(self._on_scored)
145
146 def _on_scored(self, side: str):
147 self.scores[0 if side == "left" else 1] += 1
148 # The score changes only here -> tell the retained renderer to redraw the
149 # scoreboard. The net + control labels are static, so the whole node need
150 # not redraw every frame (which `dynamic = True` would wrongly do).
151 self.queue_redraw()
152
153 def on_update(self, dt: float):
154 for paddle in (self.left_paddle, self.right_paddle):
155 dx = abs(self.ball.position.x - paddle.position.x)
156 dy = abs(self.ball.position.y - paddle.position.y)
157 if dx < PADDLE_W / 2 + BALL_R and dy < PADDLE_H / 2 + BALL_R:
158 direction = 1.0 if paddle is self.left_paddle else -1.0
159 offset = (self.ball.position.y - paddle.position.y) / (PADDLE_H / 2)
160 angle = offset * math.pi / 3
161 speed = min(self.ball.velocity.length() * 1.05, MAX_RALLY_SPEED)
162 self.ball.velocity = Vec2(math.cos(angle) * direction, math.sin(angle)) * speed
163 self.ball.position = Vec2(
164 paddle.position.x + direction * (PADDLE_W / 2 + BALL_R + 1),
165 self.ball.position.y,
166 )
167
168 def on_draw(self, renderer):
169 for y in range(0, HEIGHT, 20):
170 renderer.draw_rect((WIDTH // 2 - 1, y), (2, 10), colour=(0.31, 0.31, 0.31), filled=True)
171 # `alignment` anchors a line's right edge on x, so the scores stay clear
172 # of the net however many digits they grow to.
173 renderer.draw_text(
174 str(self.scores[0]), (WIDTH // 2 - 20, 20), scale=4, alignment="right", colour=(1.0, 1.0, 1.0)
175 )
176 renderer.draw_text(str(self.scores[1]), (WIDTH // 2 + 20, 20), scale=4, colour=(1.0, 1.0, 1.0))
177 renderer.draw_text("W/S or drag", (10, HEIGHT - 20), scale=1, colour=(0.39, 0.39, 0.39))
178 renderer.draw_text(
179 "Up/Down or drag", (WIDTH - 10, HEIGHT - 20), scale=1, alignment="right", colour=(0.39, 0.39, 0.39)
180 )
181
182
183def _selftest() -> bool:
184 """Headless: play the game through the real input path and check each mechanic.
185
186 The ball is parked in the middle for the paddle phases so a rally cannot wander
187 in and move a paddle mid-measurement, then handed a position and velocity for
188 each of the bounce, score and wall checks. Everything else is the real scene:
189 the paddles are driven by the actions the docstring advertises, and the score
190 is only ever read back from the signal the ball emits.
191 """
192 from simvx.core.testing import InputSimulator
193 from simvx.graphics.testing import assert_not_blank, save_png
194
195 PARK = 5
196 P1_UP, P1_UP_MEASURED, P1_UP_END = 10, 40, 80 # W: half a second, then held to the top
197 P1_DOWN, P1_DOWN_END = 85, 185 # S, held long enough to reach the bottom
198 P2_UP, P2_UP_END = 190, 220 # the other paddle, on its own action
199 POINTER, POINTER_END = 225, 265 # a pointer held on the left half
200 POINTER_AT = (50.0, 120.0)
201 BOUNCE, BOUNCE_SEEN = 270, 272
202 SCORE, SCORE_SEEN = 280, 283
203 WALL, WALL_SEEN = 290, 293
204
205 random.seed(11) # the serve angle is random; pin it so the run is reproducible
206 app = App(title="Pong", width=WIDTH, height=HEIGHT, visible=False)
207 scene = PongGame(name="PongGame")
208 sim = InputSimulator()
209 seen: dict[str, object] = {}
210 lowest_ball = HEIGHT * 2.0 # the ball must never leave the court through top or bottom
211 highest_ball = -HEIGHT
212
213 def on_frame(idx: int, _t: float) -> bool:
214 nonlocal lowest_ball, highest_ball
215 if idx > PARK:
216 lowest_ball = min(lowest_ball, float(scene.ball.position.y))
217 highest_ball = max(highest_ball, float(scene.ball.position.y))
218
219 if idx == PARK:
220 seen["start"] = (float(scene.left_paddle.position.y), float(scene.right_paddle.position.y))
221 scene.ball.position = Vec2(WIDTH / 2, HEIGHT / 2)
222 scene.ball.velocity = Vec2(0.0, 0.0)
223 elif idx == P1_UP:
224 sim.press_key(Key.W)
225 elif idx == P1_UP_MEASURED:
226 seen["p1_up"] = float(scene.left_paddle.position.y)
227 elif idx == P1_UP_END:
228 sim.release_key(Key.W)
229 seen["p1_top"] = float(scene.left_paddle.position.y)
230 elif idx == P1_DOWN:
231 sim.press_key(Key.S)
232 elif idx == P1_DOWN_END:
233 sim.release_key(Key.S)
234 seen["p1_bottom"] = float(scene.left_paddle.position.y)
235 seen["p2_untouched"] = float(scene.right_paddle.position.y)
236 elif idx == P2_UP:
237 sim.press_key(Key.UP)
238 elif idx == P2_UP_END:
239 sim.release_key(Key.UP)
240 seen["p2_up"] = float(scene.right_paddle.position.y)
241 seen["pointer_from"] = float(scene.left_paddle.position.y)
242 elif idx == POINTER:
243 sim.move_mouse(*POINTER_AT)
244 sim.press_mouse(MouseButton.LEFT)
245 elif idx == POINTER_END:
246 sim.release_mouse(MouseButton.LEFT)
247 seen["pointer"] = float(scene.left_paddle.position.y)
248 elif idx == BOUNCE:
249 # Nose the ball into the left paddle's collision band, moving away from it.
250 scene.left_paddle.position.y = 300.0
251 scene.ball.position = Vec2(scene.left_paddle.position.x + PADDLE_W / 2 + BALL_R - 1, 300.0)
252 scene.ball.velocity = Vec2(-300.0, 0.0)
253 seen["bounce_from"] = 300.0
254 elif idx == BOUNCE_SEEN:
255 seen["bounce"] = (float(scene.ball.velocity.x), float(scene.ball.velocity.length()))
256 elif idx == SCORE:
257 seen["scores_before"] = list(scene.scores)
258 scene.ball.position = Vec2(5.0, 300.0)
259 scene.ball.velocity = Vec2(-600.0, 0.0)
260 elif idx == SCORE_SEEN:
261 seen["scores"] = list(scene.scores)
262 seen["reserved"] = float(scene.ball.position.x)
263 elif idx == WALL:
264 scene.ball.position = Vec2(WIDTH / 2, BALL_R + 2)
265 scene.ball.velocity = Vec2(0.0, -400.0)
266 elif idx == WALL_SEEN:
267 seen["wall"] = float(scene.ball.velocity.y)
268 return True
269
270 frames = app.run_headless(scene, frames=320, on_frame=on_frame, capture_frames=[319])
271 assert_not_blank(frames[0])
272 save_png(frames[0], "/tmp/pong_test.png")
273
274 ok = True
275
276 def check(label: str, passed: bool, detail: str) -> None:
277 nonlocal ok
278 ok = ok and passed
279 print(f"{'ok ' if passed else 'FAIL'} {label}: {detail}")
280
281 speed = float(scene.left_paddle.speed)
282 half_h = scene.left_paddle.half_h
283
284 # W moves the left paddle up at exactly the speed its Property declares.
285 travelled = seen["start"][0] - seen["p1_up"]
286 expected = speed * (P1_UP_MEASURED - P1_UP) / 60.0
287 check(
288 "W moves the left paddle up at `speed`",
289 abs(travelled - expected) < 2.0,
290 f"{travelled:.1f}px in {(P1_UP_MEASURED - P1_UP) / 60:.2f}s (expected {expected:.1f})",
291 )
292 # Held past the top edge it stops there rather than leaving the court.
293 check("and it stops at the top edge", abs(seen["p1_top"] - half_h) < 0.01, f"y={seen['p1_top']:.2f}")
294 check(
295 "S drives it back to the bottom edge",
296 abs(seen["p1_bottom"] - (HEIGHT - half_h)) < 0.01,
297 f"y={seen['p1_bottom']:.2f}",
298 )
299
300 # Each paddle answers only its own actions: W and S never moved the right one.
301 check(
302 "the right paddle ignored player one's keys",
303 abs(seen["p2_untouched"] - seen["start"][1]) < 0.01,
304 f"still at y={seen['p2_untouched']:.2f}",
305 )
306 p2_travelled = seen["start"][1] - seen["p2_up"]
307 check(
308 "Up moves the right paddle on its own action",
309 abs(p2_travelled - speed * (P2_UP_END - P2_UP) / 60.0) < 2.0,
310 f"{p2_travelled:.1f}px",
311 )
312
313 # With no key held, a pointer on a paddle's half of the screen drags it, and it
314 # closes on the pointer at the same clamped step rather than teleporting.
315 pointer_move = seen["pointer_from"] - seen["pointer"]
316 reachable = min(seen["pointer_from"] - POINTER_AT[1], speed * (POINTER_END - POINTER) / 60.0)
317 check(
318 "a pointer held on the left half drags that paddle towards it",
319 abs(pointer_move - reachable) < 2.0 and seen["pointer"] >= POINTER_AT[1] - 1.0,
320 f"closed {pointer_move:.1f}px of the {seen['pointer_from'] - POINTER_AT[1]:.0f} to the pointer",
321 )
322
323 # A paddle hit sends the ball back the other way, 5% faster than it arrived.
324 bounce_vx, bounce_speed = seen["bounce"]
325 check(
326 "the left paddle bounces the ball back and speeds it up 5%",
327 bounce_vx > 0 and abs(bounce_speed - 300.0 * 1.05) < 1.0,
328 f"vx={bounce_vx:.1f} speed 300.0 -> {bounce_speed:.1f}",
329 )
330
331 # Past a paddle, the ball emits `scored` and the root's handler moves the score.
332 before, after = seen["scores_before"], seen["scores"]
333 check(
334 "the ball leaving the left edge scores for the right player",
335 after[1] == before[1] + 1 and after[0] == before[0],
336 f"{before} -> {after}",
337 )
338 check(
339 "and the ball is served again from the middle",
340 abs(seen["reserved"] - WIDTH / 2) < 40.0,
341 f"x={seen['reserved']:.1f}",
342 )
343
344 # The top and bottom walls reflect rather than absorb, and the ball never escapes.
345 check("the top wall reflects the ball downwards", seen["wall"] > 0, f"vy {-400.0:.0f} -> {seen['wall']:.0f}")
346 check(
347 "the ball stayed inside the court all game",
348 lowest_ball >= BALL_R - 0.01 and highest_ball <= HEIGHT - BALL_R + 0.01,
349 f"y ranged {lowest_ball:.1f}..{highest_ball:.1f} (court is {BALL_R}..{HEIGHT - BALL_R})",
350 )
351
352 print("screenshot: /tmp/pong_test.png")
353 print("SELFTEST:", "PASS" if ok else "FAIL")
354 return ok
355
356
357if __name__ == "__main__":
358 import sys
359
360 if "--test" in sys.argv:
361 sys.exit(0 if _selftest() else 1)
362 App(title="Pong", width=WIDTH, height=HEIGHT).run(PongGame())