Pong¶
Complete two-player game in ~150 lines.
▶ Run in browserTags: game input-actions signals collision
A classic Pong demonstrating input actions, signals, collision detection, and game-state management. Player 1 uses W/S, player 2 uses Up/Down arrows.
What you will learn¶
Input actions: Bind keys to named actions with
InputMap.add_action().Input.get_strength(): Read analogue input strength for smooth movement.
Signals: Decouple game events (the ball emits
scoredwhen it passes a paddle).Collision: Manual AABB overlap for paddle-ball bouncing.
Game state: Track and display scores.
How it works¶
Three node types compose the game:
Paddlereads two input actions (up/down) and clamps position to the screen.Ballmoves at a velocity, bounces off top/bottom edges, and emits ascoredsignal when it exits left or right.PongGame(root) declaresinput_actions = {...}at class scope, creates paddles and ball inon_ready(), connects thescoredsignal to update the score, and handles paddle-ball collision inon_process()by reflecting the ball’s velocity based on where it hits the paddle.
The input_actions class attribute is the canonical registration path: the
scene tree consumes it at mount and re-applies on every change_scene swap.
It also works correctly under the web exporter, which instantiates the root
class directly without invoking main().
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 = ["game", "input-actions", "signals", "collision"]
8# web = { root = "PongGame" }
9# ///
10
11## What you will learn
12
13- **Input actions**: Bind keys to named actions with `InputMap.add_action()`.
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_process()` 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_process(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_process(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))
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_process(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())