Pong

a complete two-player game.

▶ Run in browser

Tags: 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

Node2D

The game, each paddle and the ball, each drawing itself in on_draw

Property

A paddle’s speed, action and side; the two scores, which redraw the board when they change

input_context

One typed action per paddle: Axis1D(low=Key.W, high=Key.S) and a controller axis read as one float

Input.pointers

A mouse or finger held on a half of the court steers that half’s paddle

Signal

The ball’s scored, which the game connects to the score handler

CanvasLayer, Label

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())