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.
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))
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):
dy = Input.get_strength(self.down_action) - Input.get_strength(self.up_action)
self.position.y = max(self.half_h, min(HEIGHT - self.half_h,
self.position.y + dy * self.speed * dt))
2. 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()
3. 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)
4. 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
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.
5. 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.
Run it¶
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.
5
6# /// simvx
7# tags = ["tutorial", "game", "input-actions", "signals", "collision"]
8# web = { root = "PongGame", width = 800, height = 600, responsive = true }
9# ///
10
11## What you will learn
12
13- **Input actions**: Bind keys to named actions with the `input_actions` class attribute.
14- **Input.get_strength()**: Read analogue input strength for smooth movement.
15- **Signals**: Decouple game events (the ball emits `scored` when it passes a paddle).
16- **Collision**: Manual AABB overlap for paddle-ball bouncing.
17- **Game state**: Track and display scores.
18
19## How it works
20
21Three node types compose the game:
22
23- `Paddle` reads two input actions (up/down) and clamps position to the screen.
24- `Ball` moves at a velocity, bounces off top/bottom edges, and emits a
25 `scored` signal when it exits left or right.
26- `PongGame` (root) declares `input_actions = {...}` at class scope, creates
27 paddles and ball in `on_ready()`, connects the `scored` signal to update
28 the score, and handles paddle-ball collision in `on_update()` by
29 reflecting the ball's velocity based on where it hits the paddle.
30
31The `input_actions` class attribute is the canonical registration path: the
32scene tree consumes it at mount and re-applies on every `change_scene` swap.
33It also works correctly under the web exporter, which instantiates the root
34class directly without invoking `main()`.
35"""
36
37import math
38import random
39
40from simvx.core import Input, Key, Node2D, Property, Signal, Vec2
41from simvx.graphics import App
42
43WIDTH, HEIGHT = 800, 600
44PADDLE_W, PADDLE_H = 12, 80
45BALL_R = 8
46
47
48class Paddle(Node2D):
49 speed = Property(400.0, range=(100, 800))
50 half_h = PADDLE_H // 2
51
52 def __init__(self, up_action: str, down_action: str, **kwargs):
53 super().__init__(**kwargs)
54 self.up_action = up_action
55 self.down_action = down_action
56
57 def on_update(self, dt: float):
58 dy = Input.get_strength(self.down_action) - Input.get_strength(self.up_action)
59 self.position.y = max(self.half_h, min(HEIGHT - self.half_h, self.position.y + dy * self.speed * dt))
60
61 def on_draw(self, renderer):
62 x, y = self.position.x - PADDLE_W // 2, self.position.y - self.half_h
63 renderer.draw_rect((x, y), (PADDLE_W, PADDLE_H), colour=(1.0, 1.0, 1.0, 1.0), filled=True)
64
65
66class Ball(Node2D):
67 speed = Property(350.0, range=(200, 600))
68
69 def __init__(self, **kwargs):
70 super().__init__(**kwargs)
71 self.velocity = Vec2()
72 self.scored = Signal()
73 self.reset()
74
75 def reset(self):
76 self.position = Vec2(WIDTH / 2, HEIGHT / 2)
77 angle = random.choice([-1, 1]) * random.uniform(-math.pi / 4, math.pi / 4)
78 direction = random.choice([-1, 1])
79 self.velocity = Vec2(math.cos(angle) * direction, math.sin(angle)) * self.speed
80
81 def on_update(self, dt: float):
82 self.position += self.velocity * dt
83 if self.position.y < BALL_R:
84 self.position.y = BALL_R
85 self.velocity.y = abs(self.velocity.y)
86 elif self.position.y > HEIGHT - BALL_R:
87 self.position.y = HEIGHT - BALL_R
88 self.velocity.y = -abs(self.velocity.y)
89 if self.position.x < 0:
90 self.scored.emit("right")
91 self.reset()
92 elif self.position.x > WIDTH:
93 self.scored.emit("left")
94 self.reset()
95
96 def on_draw(self, renderer):
97 renderer.draw_circle(self.position, BALL_R, colour=(1.0, 1.0, 1.0, 1.0), filled=True)
98
99
100class PongGame(Node2D):
101 input_actions = {
102 "p1_up": [Key.W],
103 "p1_down": [Key.S],
104 "p2_up": [Key.UP],
105 "p2_down": [Key.DOWN],
106 }
107
108 def on_ready(self):
109 self.left_paddle = self.add_child(Paddle("p1_up", "p1_down", name="Left", position=Vec2(30, HEIGHT / 2)))
110 self.right_paddle = self.add_child(
111 Paddle("p2_up", "p2_down", name="Right", position=Vec2(WIDTH - 30, HEIGHT / 2))
112 )
113 self.ball = self.add_child(Ball(name="Ball"))
114 self.scores = [0, 0]
115 self.ball.scored.connect(self._on_scored)
116
117 def _on_scored(self, side: str):
118 self.scores[0 if side == "left" else 1] += 1
119
120 def on_update(self, dt: float):
121 for paddle in (self.left_paddle, self.right_paddle):
122 dx = abs(self.ball.position.x - paddle.position.x)
123 dy = abs(self.ball.position.y - paddle.position.y)
124 if dx < PADDLE_W / 2 + BALL_R and dy < PADDLE_H / 2 + BALL_R:
125 direction = 1.0 if paddle is self.left_paddle else -1.0
126 offset = (self.ball.position.y - paddle.position.y) / (PADDLE_H / 2)
127 angle = offset * math.pi / 3
128 speed = self.ball.velocity.length() * 1.05
129 self.ball.velocity = Vec2(math.cos(angle) * direction, math.sin(angle)) * speed
130 self.ball.position = Vec2(
131 paddle.position.x + direction * (PADDLE_W / 2 + BALL_R + 1),
132 self.ball.position.y,
133 )
134
135 def on_draw(self, renderer):
136 for y in range(0, HEIGHT, 20):
137 renderer.draw_rect((WIDTH // 2 - 1, y), (2, 10), colour=(0.31, 0.31, 0.31), filled=True)
138 renderer.draw_text(str(self.scores[0]), (WIDTH // 2 - 60, 20), scale=4, colour=(1.0, 1.0, 1.0))
139 renderer.draw_text(str(self.scores[1]), (WIDTH // 2 + 40, 20), scale=4, colour=(1.0, 1.0, 1.0))
140 renderer.draw_text("W/S", (10, HEIGHT - 20), scale=1, colour=(0.39, 0.39, 0.39))
141 renderer.draw_text("Up/Down", (WIDTH - 80, HEIGHT - 20), scale=1, colour=(0.39, 0.39, 0.39))
142
143
144if __name__ == "__main__":
145 App(title="Pong", width=WIDTH, height=HEIGHT).run(PongGame())