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.

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