Pong¶
a complete two-player game.
▶ Run in browserTags: tutorial game input-actions signals collision
Pong¶
A complete two-player game: typed input actions move the paddles, a signal reports each point, and a hand-written overlap test bounces the ball.
left paddle |
right paddle |
|
|---|---|---|
keyboard |
W / S |
Up / Down |
controller |
left stick |
right stick |
touch or mouse |
hold on the left half |
hold on the right half |
What it uses¶
Piece |
Job in this game |
|---|---|
|
The game, each paddle and the ball, each drawing itself in |
|
A paddle’s |
|
One typed action per paddle: |
|
A mouse or finger held on a half of the court steers that half’s paddle |
|
The ball’s |
|
The control hints along the bottom |
The ball and the score never reference each other, and every position is
measured from self.viewport_size, so the court fits any window.
The full walkthrough is the Your First 2D Game tutorial.
Run it¶
simvx examples run tutorials_pong
simvx examples copy tutorials_pong --to pong # an editable copy
python pong/main.py
Next steps¶
Gem Collector: a first 3D game, with physics bodies, a timer and an end screen.
Monolith to Composed: how to split a game into small nodes.
Source¶
1# /// simvx
2# tags = ["tutorial", "game", "input-actions", "signals", "collision"]
3# web = { width = 800, height = 600, responsive = true }
4# ///
5"""Pong: a complete two-player game.
6
7A complete two-player game: typed input actions move the paddles, a signal reports
8each point, and a hand-written overlap test bounces the ball.
9
10| | left paddle | right paddle |
11|---|---|---|
12| keyboard | W / S | Up / Down |
13| controller | left stick | right stick |
14| touch or mouse | hold on the left half | hold on the right half |
15
16## One typed action per paddle
17
18`PongGame` declares its actions in a class-level `input_context`. An action is
19typed by its bindings: `Axis1D(low=Key.W, high=Key.S)` composes two keys into one
20signed float, and a bare controller axis such as `Controller.LEFT_Y` is another
21source of the same float. When both are held the one further from rest wins, so a
22`Paddle` reads `Input.value(self.action)` (-1.0 is up, 1.0 is down) and needs no
23code for either device. One class serves both players: only its `action` and
24`side` Properties differ.
25
26## Pointer control for mouse and touch
27
28With the action at rest, a paddle closes on a pointer held on its half of the
29screen. `Input.pointers` holds the mouse, every finger and a pen, and `contact` is
30true for a finger on the glass or the mouse with its button held, so one loop
31covers the desktop and the phone, and two fingers drive both paddles at once. The
32paddle moves toward the pointer at its own `speed` rather than snapping to it, so
33a drag cannot outrun a keyboard player.
34
35## The ball announces each point
36
37`Ball` integrates its velocity, reflects off the top and bottom walls, and when it
38leaves the left or right edge calls its class-level `scored` signal with the
39`Side` that won the point, then serves again from the middle. It never touches the
40score: `PongGame` connects `scored` to a handler that bumps `left_score` or
41`right_score`. Those are Properties, so changing one redraws the board.
42
43## Collision lives in the parent
44
45`PongGame.on_update` tests each paddle against the ball with an axis-aligned
46overlap. On a hit it sends the ball back at an angle set by where it struck the
47paddle, so players can aim, and 5% faster, up to `MAX_RALLY_SPEED`. The cap
48matters: the overlap is tested once a frame, so once a frame's step outgrows the
49paddle's collision band the ball would pass straight through it. A physics-driven
50game would use `CharacterBody2D` and `move_and_slide` instead; the manual version
51keeps every moving part visible.
52
53## Layout follows the window
54
55Every position comes from `self.viewport_size`: each paddle keeps its distance
56from its own end of the court, and the net and scores are measured from the
57centre. The left score passes `align="right"`, which puts the text's right edge on
58the given x, so it stays clear of the net at any number of digits.
59"""
60
61import math
62import random
63from enum import StrEnum
64
65from simvx.core import Axis1D, CanvasLayer, Controller, Input, Key, Node2D, Property, Signal, Vec2
66from simvx.core.ui import Anchor, Label
67from simvx.graphics import App
68
69PADDLE_W, PADDLE_H = 12, 80
70PADDLE_INSET = 30 # distance from each paddle to its end of the court
71BALL_R = 8
72MAX_RALLY_SPEED = 900.0
73WHITE = (1.0, 1.0, 1.0, 1.0)
74
75
76class Side(StrEnum):
77 LEFT = "left"
78 RIGHT = "right"
79
80
81class Paddle(Node2D):
82 speed = Property(400.0, range=(100, 800))
83 action = Property("p1_move")
84 side = Property(Side.LEFT)
85
86 def on_update(self, dt: float):
87 court_w, court_h = self.viewport_size
88 self.position.x = PADDLE_INSET if self.side == Side.LEFT else court_w - PADDLE_INSET
89 step = self.speed * dt
90 move = Input.value(self.action) * step
91 if not move and (target := self.held_pointer_y(court_w)) is not None:
92 move = max(-step, min(step, target - self.position.y))
93 self.position.y = max(PADDLE_H / 2, min(court_h - PADDLE_H / 2, self.position.y + move))
94
95 def held_pointer_y(self, court_w: float) -> float | None:
96 """The y of the first pointer held on this paddle's half of the court."""
97 for pointer in Input.pointers.values():
98 x, y = pointer.position
99 if pointer.contact and (x < court_w / 2) == (self.side == Side.LEFT):
100 return y
101 return None
102
103 def on_draw(self, renderer):
104 renderer.rect((-PADDLE_W / 2, -PADDLE_H / 2), (PADDLE_W, PADDLE_H), fill=WHITE)
105
106
107class Ball(Node2D):
108 speed = Property(350.0, range=(200, 600))
109 scored = Signal()
110
111 def on_ready(self):
112 self.serve()
113
114 def serve(self):
115 """Start from the middle of the court at a random shallow angle."""
116 self.position = self.viewport_size / 2
117 angle = random.uniform(-math.pi / 4, math.pi / 4)
118 self.velocity = Vec2(math.cos(angle) * random.choice([-1, 1]), math.sin(angle)) * self.speed
119
120 def on_update(self, dt: float):
121 court_w, court_h = self.viewport_size
122 self.position += self.velocity * dt
123 if self.position.y < BALL_R:
124 self.position.y = BALL_R
125 self.velocity.y = abs(self.velocity.y)
126 elif self.position.y > court_h - BALL_R:
127 self.position.y = court_h - BALL_R
128 self.velocity.y = -abs(self.velocity.y)
129 if self.position.x < 0:
130 self.scored.emit(Side.RIGHT)
131 self.serve()
132 elif self.position.x > court_w:
133 self.scored.emit(Side.LEFT)
134 self.serve()
135
136 def on_draw(self, renderer):
137 renderer.circle((0, 0), BALL_R, fill=WHITE)
138
139
140class PongGame(Node2D):
141 input_context = {
142 "p1_move": [Axis1D(low=Key.W, high=Key.S), Controller.LEFT_Y],
143 "p2_move": [Axis1D(low=Key.UP, high=Key.DOWN), Controller.RIGHT_Y],
144 }
145 left_score = Property(0)
146 right_score = Property(0)
147
148 def on_ready(self):
149 middle = self.viewport_size.y / 2
150 self.left_paddle = self.add_child(Paddle(action="p1_move", side=Side.LEFT, position=(0, middle)))
151 self.right_paddle = self.add_child(Paddle(action="p2_move", side=Side.RIGHT, position=(0, middle)))
152 self.ball = self.add_child(Ball())
153 self.ball.scored.connect(self.on_scored)
154 self.add_child(controls_help())
155
156 def on_scored(self, side: Side):
157 if side == Side.LEFT:
158 self.left_score += 1
159 else:
160 self.right_score += 1
161
162 def on_update(self, dt: float):
163 ball = self.ball
164 for paddle, direction in ((self.left_paddle, 1.0), (self.right_paddle, -1.0)):
165 offset = ball.position - paddle.position
166 if abs(offset.x) < PADDLE_W / 2 + BALL_R and abs(offset.y) < PADDLE_H / 2 + BALL_R:
167 angle = offset.y / (PADDLE_H / 2) * math.pi / 3
168 speed = min(ball.velocity.length() * 1.05, MAX_RALLY_SPEED)
169 ball.velocity = Vec2(math.cos(angle) * direction, math.sin(angle)) * speed
170 ball.position.x = paddle.position.x + direction * (PADDLE_W / 2 + BALL_R + 1)
171
172 def on_draw(self, renderer):
173 court_w, court_h = self.viewport_size
174 for y in range(0, int(court_h), 20):
175 renderer.rect((court_w / 2 - 1, y), (2, 10), fill=(0.31, 0.31, 0.31))
176 renderer.text(str(self.left_score), (court_w / 2 - 20, 20), fill=WHITE, scale=4, align="right")
177 renderer.text(str(self.right_score), (court_w / 2 + 20, 20), fill=WHITE, scale=4)
178
179
180# Example help overlay: not part of the technique shown.
181def controls_help() -> CanvasLayer:
182 layer = CanvasLayer()
183 for text, anchor in (
184 ("W/S, left stick, or hold this half", Anchor.BOTTOM_LEFT),
185 ("Up/Down, right stick, or hold this half", Anchor.BOTTOM_RIGHT),
186 ):
187 label = layer.add_child(Label(text, anchor=anchor, inset=10, font_size=14))
188 label.text_colour = (0.39, 0.39, 0.39, 1.0)
189 return layer
190
191
192if __name__ == "__main__":
193 App(title="Pong", width=800, height=600).run(PongGame())