Pong

Complete two-player game in ~150 lines.

▶ Run in browser

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

  • Paddle reads two input actions (up/down) and clamps position to the screen.

  • Ball moves at a velocity, bounces off top/bottom edges, and emits a scored signal when it exits left or right.

  • PongGame (root) declares input_actions = {...} at class scope, creates paddles and ball in on_ready(), connects the scored signal to update the score, and handles paddle-ball collision in on_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())