Pong¶

Complete two-player game in ~150 lines.

â–¶ Run in browser

Tags: 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 + CollisionShape2D and move_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())