Your first 2D game

Build Pong for two players: keyboard, controller, mouse and touch all work. Every snippet below is from the shipped, tested example.

Two white paddles at either end of a dark court, a ball between them, a dashed net and the score at the top

Play it in your browser first: Pong. Then get your own copy and run it:

pip install --pre "simvx[examples]"
simvx examples copy tutorials_pong --to pong
python pong/main.py

From a source checkout, prefix commands with uv run. Read the whole file on the source host.

1. Sizes and sides

Everything comes from simvx.core and simvx.core.ui; App from simvx.graphics opens the window. A few constants size the pieces, and a StrEnum names the two sides of the court:

import math
import random
from enum import StrEnum

from simvx.core import Axis1D, CanvasLayer, Controller, Input, Key, Node2D, Property, Signal, Vec2
from simvx.core.ui import Anchor, Label
from simvx.graphics import App

PADDLE_W, PADDLE_H = 12, 80
PADDLE_INSET = 30  # distance from each paddle to its end of the court
BALL_R = 8
MAX_RALLY_SPEED = 900.0
WHITE = (1.0, 1.0, 1.0, 1.0)


class Side(StrEnum):
    LEFT = "left"
    RIGHT = "right"


The court itself has no constant: everything is measured from the window, so the game fits any window size.

2. A paddle

class Paddle(Node2D):
    speed = Property(400.0, range=(100, 800))
    action = Property("p1_move")
    side = Property(Side.LEFT)

    def on_update(self, dt: float):
        court_w, court_h = self.viewport_size
        self.position.x = PADDLE_INSET if self.side == Side.LEFT else court_w - PADDLE_INSET
        step = self.speed * dt
        move = Input.value(self.action) * step
        if not move and (target := self.held_pointer_y(court_w)) is not None:
            move = max(-step, min(step, target - self.position.y))
        self.position.y = max(PADDLE_H / 2, min(court_h - PADDLE_H / 2, self.position.y + move))

    def held_pointer_y(self, court_w: float) -> float | None:
        """The y of the first pointer held on this paddle's half of the court."""
        for pointer in Input.pointers.values():
            x, y = pointer.position
            if pointer.contact and (x < court_w / 2) == (self.side == Side.LEFT):
                return y
        return None

    def on_draw(self, renderer):
        renderer.rect((-PADDLE_W / 2, -PADDLE_H / 2), (PADDLE_W, PADDLE_H), fill=WHITE)
  • Node2D is the base for 2D game objects. on_update(dt) runs every frame, with dt in seconds.

  • Property declares a value the editor can show and a scene can save, with optional range validation. One class serves both players: only action and side differ.

  • self.viewport_size is the size of the window in the node’s units, so each paddle keeps its distance from its own end of the court whatever the window is.

  • Input.value(self.action) reads the paddle’s action as a float from -1.0 (up) to 1.0 (down), from whichever device is in use.

  • With the action at rest, the paddle closes on a pointer held on its half of the screen. Input.pointers holds the mouse, every finger and a pen, and contact is true for a finger on the glass or the mouse with its button held, so two fingers can drive both paddles at once.

  • on_draw(renderer) draws in the node’s own space: (0, 0) is the node’s position, so moving the node moves the drawing. rect() and circle() take fill= and stroke=, line() takes stroke=, and text() takes fill=.

3. The ball

class Ball(Node2D):
    speed = Property(350.0, range=(200, 600))
    scored = Signal()

    def on_ready(self):
        self.serve()

    def serve(self):
        """Start from the middle of the court at a random shallow angle."""
        self.position = self.viewport_size / 2
        angle = random.uniform(-math.pi / 4, math.pi / 4)
        self.velocity = Vec2(math.cos(angle) * random.choice([-1, 1]), math.sin(angle)) * self.speed

    def on_update(self, dt: float):
        court_w, court_h = self.viewport_size
        self.position += self.velocity * dt
        if self.position.y < BALL_R:
            self.position.y = BALL_R
            self.velocity.y = abs(self.velocity.y)
        elif self.position.y > court_h - BALL_R:
            self.position.y = court_h - BALL_R
            self.velocity.y = -abs(self.velocity.y)
        if self.position.x < 0:
            self.scored.emit(Side.RIGHT)
            self.serve()
        elif self.position.x > court_w:
            self.scored.emit(Side.LEFT)
            self.serve()

    def on_draw(self, renderer):
        renderer.circle((0, 0), BALL_R, fill=WHITE)

The ball moves itself and bounces off the top and bottom walls. When it leaves the left or right edge, it emits its scored signal with the side that won the point and serves again. A signal is declared on the class and fired with emit. The ball never touches the score: whoever cares connects to the signal.

4. The game

The root node declares the actions and builds the scene:

class PongGame(Node2D):
    input_context = {
        "p1_move": [Axis1D(low=Key.W, high=Key.S), Controller.LEFT_Y],
        "p2_move": [Axis1D(low=Key.UP, high=Key.DOWN), Controller.RIGHT_Y],
    }
    left_score = Property(0)
    right_score = Property(0)

    def on_ready(self):
        middle = self.viewport_size.y / 2
        self.left_paddle = self.add_child(Paddle(action="p1_move", side=Side.LEFT, position=(0, middle)))
        self.right_paddle = self.add_child(Paddle(action="p2_move", side=Side.RIGHT, position=(0, middle)))
        self.ball = self.add_child(Ball())
        self.ball.scored.connect(self.on_scored)
        self.add_child(controls_help())

An input_context on the root declares the game’s actions for the whole tree while this scene is running. An action is typed by its bindings: Axis1D(low=Key.W, high=Key.S) composes two keys into one signed float, and a controller axis such as Controller.LEFT_Y is another source of the same float. When both are in use, the one further from rest wins.

add_child() attaches a node to the tree, where it is updated and drawn automatically. connect() routes the ball’s scored signal to the game’s handler. The board is drawn from self.viewport_size, so it follows the window. controls_help(), at the end of the file, builds the key hints along the bottom.

5. Keeping score

def on_scored(self, side: Side):
    if side == Side.LEFT:
        self.left_score += 1
    else:
        self.right_score += 1

The scores are Properties, so changing one redraws the board. The 2D renderer re-runs on_draw only when a Property or the node’s transform changes; a node that animates from other state sets dynamic = True (see Node system).

6. Hitting the ball

def on_update(self, dt: float):
    ball = self.ball
    for paddle, direction in ((self.left_paddle, 1.0), (self.right_paddle, -1.0)):
        offset = ball.position - paddle.position
        if abs(offset.x) < PADDLE_W / 2 + BALL_R and abs(offset.y) < PADDLE_H / 2 + BALL_R:
            angle = offset.y / (PADDLE_H / 2) * math.pi / 3
            speed = min(ball.velocity.length() * 1.05, MAX_RALLY_SPEED)
            ball.velocity = Vec2(math.cos(angle) * direction, math.sin(angle)) * speed
            ball.position.x = paddle.position.x + direction * (PADDLE_W / 2 + BALL_R + 1)

Each frame the game tests each paddle against the ball with an axis-aligned overlap. On a hit it sends the ball back at an angle set by where it struck the paddle, so players can aim, and 5% faster, up to MAX_RALLY_SPEED. The cap matters: the test runs once a frame, so a ball moving further than the paddle’s width in one frame would pass straight through it.

Hand-written collision keeps every moving part visible. A game with more shapes would use the physics nodes instead, as Your first 3D game does.

7. Drawing the court

def on_draw(self, renderer):
    court_w, court_h = self.viewport_size
    for y in range(0, int(court_h), 20):
        renderer.rect((court_w / 2 - 1, y), (2, 10), fill=(0.31, 0.31, 0.31))
    renderer.text(str(self.left_score), (court_w / 2 - 20, 20), fill=WHITE, scale=4, align="right")
    renderer.text(str(self.right_score), (court_w / 2 + 20, 20), fill=WHITE, scale=4)

The net and the scores are measured from the centre of the window. align="right" puts the left score’s right edge on the given x, so it stays clear of the net at any number of digits.

Finally, App opens the window and runs the game:

if __name__ == "__main__":
    App(title="Pong", width=800, height=600).run(PongGame())

Next steps

  • Copy the finished game: simvx examples copy tutorials_pong --to pong (source).

  • Change a paddle’s speed or MAX_RALLY_SPEED and play again.

  • Your first 3D game: a first 3D game, with physics bodies, a timer and an end screen.

  • From monolithic to composed: split a game into small nodes.

  • Examples Gallery: every example, from sprites and tilemaps to particles and navigation.