Your First 2D Game

Build a playable Pong game from scratch in 7 steps. Each section adds to the same project.

The finished, runnable version is the Pong tutorial at examples/tutorials/pong/ (playable in the browser from the examples gallery):

uv run python examples/tutorials/pong/main.py

It uses the declarative input_actions = {...} class attribute (the web-safe registration path) rather than add_action() in on_ready() shown below; both work, the class attribute is preferred.

1. A Window and a Paddle

from simvx.core import Node2D, Vec2
from simvx.graphics import App

WIDTH, HEIGHT = 800, 600

class Paddle(Node2D):
    def on_draw(self, renderer):
        renderer.draw_rect((self.position.x - 6, self.position.y - 40), (12, 80), colour=(1.0, 1.0, 1.0), filled=True)

App(title="Pong", width=WIDTH, height=HEIGHT).run(Paddle(position=Vec2(30, HEIGHT / 2)))

Node2D is the base for all 2D game objects. Override on_draw(renderer) to render – the renderer provides draw_rect(), draw_circle(), and draw_text(). App creates a Vulkan window and runs the game loop.

How 2D drawing is retained

SimVX’s 2D renderer is retained, not immediate-mode. It caches the geometry each on_draw produces and re-runs on_draw only when the node is marked dirty, so most nodes never re-emit their draw commands and a busy scene stays cheap. Three rules cover every case:

  • Draw from Property state, or from the node’s own transform (self.position, rotation, scale, including +=). A write to either auto-marks the node dirty, so the change shows with no extra code. This is the common case – the moving paddle and ball below just work:

    class Paddle(Node2D):
        speed = Property(400.0)                # a Property write auto-dirties
    
        def on_update(self, dt):
            self.position.y += self.speed * dt  # moving the transform auto-dirties
    
        def on_draw(self, renderer):
            renderer.draw_rect((self.position.x - 6, self.position.y - 40), (12, 80), colour=(1, 1, 1), filled=True)
    
  • Per-frame animation read from non-Property state (an elapsed timer, a pulsing radius, a scrolling offset, particle motion). Set dynamic = True on the node to opt that one node into a per-frame on_draw re-run while the rest of the scene stays retained:

    class Pulse(Node2D):
        dynamic = True                        # on_draw reads self.t every frame
    
        def on_ready(self):
            self.t = 0.0
    
        def on_update(self, dt):
            self.t += dt
    
        def on_draw(self, renderer):
            r = 12 + 8 * math.sin(self.t)
            renderer.draw_circle(self.position, r, colour=(0.4, 0.8, 1, 1), filled=True)
    
  • A discrete value changed on an event that is not a Property (a score bumped in a signal handler, a menu selection changed on key-press, a HUD value updated in a callback). Call self.queue_redraw() at the site that mutates it. Prefer this over dynamic when the content changes rarely – re-emitting a large static draw every frame is wasteful:

    def _on_scored(self, side):
        self.scores[0 if side == "left" else 1] += 1
        self.queue_redraw()                   # scores isn't a Property -> dirty by hand
    

The decision in one line: Property or transform -> nothing to do; per-frame animation -> dynamic = True; discrete non-Property change -> queue_redraw() at the mutation site. In dev builds (Node.strict_errors, on by default) a freeze detector samples clean nodes and logs a one-line warning if an on_draw changed without a dirty signal, naming the node and suggesting dynamic = True or queue_redraw().

2. Move the Paddle

from simvx.core import Node2D, Vec2, InputMap, Key, Input, Property
from simvx.graphics import App

WIDTH, HEIGHT = 800, 600
HALF_H = 40

class Paddle(Node2D):
    speed = Property(400.0, range=(100, 800))

    def on_ready(self):
        InputMap.add_action("up", [Key.W, Key.UP])
        InputMap.add_action("down", [Key.S, Key.DOWN])

    def on_update(self, dt: float):
        dy = Input.get_strength("down") - Input.get_strength("up")
        new_y = max(HALF_H, min(HEIGHT - HALF_H, self.position.y + dy * self.speed * dt))
        self.position = Vec2(self.position.x, new_y)

    def on_draw(self, renderer):
        renderer.draw_rect((self.position.x - 6, self.position.y - HALF_H), (12, 80), colour=(1.0, 1.0, 1.0), filled=True)

App(title="Pong", width=WIDTH, height=HEIGHT).run(Paddle(position=Vec2(30, HEIGHT / 2)))

Property declares editor-visible, serializable values with optional range validation. InputMap.add_action() binds named actions to Key enums: register inside the root node’s on_ready(), not at module scope, so the bindings survive web export. Input.get_strength() returns 0.0-1.0 for digital keys. on_update(dt) runs every frame; dt is the delta time in seconds.

3. Add a Ball

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

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.velocity = Vec2(self.speed, self.speed * 0.5)

    def on_update(self, dt: float):
        self.position += self.velocity * dt

    def on_draw(self, renderer):
        renderer.draw_circle(self.position, 8, colour=(1.0, 1.0, 1.0), filled=True)

Add it as a child in a root node’s on_ready():

class PongGame(Node2D):
    def on_ready(self):
        self.paddle = self.add_child(Paddle(name="Paddle", position=Vec2(30, HEIGHT / 2)))
        self.ball = self.add_child(Ball(name="Ball", position=Vec2(WIDTH / 2, HEIGHT / 2)))

add_child() attaches nodes to the scene tree. Children inherit their parent’s coordinate space and are processed automatically.

4. Bounce and Collide

Add wall bouncing and paddle collision to the ball:

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

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.velocity = Vec2(self.speed, self.speed * 0.5)

    def on_update(self, dt: float):
        self.position += self.velocity * dt

        # Bounce off top/bottom walls
        if self.position.y < 8 or self.position.y > HEIGHT - 8:
            self.velocity = Vec2(self.velocity.x, -self.velocity.y)

    def on_draw(self, renderer):
        renderer.draw_circle(self.position, 8, colour=(1.0, 1.0, 1.0), filled=True)

Check paddle collision in the parent’s on_update():

def on_update(self, dt: float):
    bx, by = self.ball.position.x, self.ball.position.y
    px, py = self.paddle.position.x, self.paddle.position.y
    if abs(bx - px) < 14 and abs(by - py) < 48:
        self.ball.velocity = Vec2(abs(self.ball.velocity.x), self.ball.velocity.y)

For production games, use CharacterBody2D with CollisionShape2D and move_and_slide(dt) – see examples/demos/platformer.py.

5. Score and Signals

Signal provides decoupled event communication:

from simvx.core import Signal

class Ball(Node2D):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.scored = Signal()  # emits when ball passes a paddle
        # ...

    def on_update(self, dt: float):
        self.position += self.velocity * dt
        if self.position.x < 0:
            self.scored.emit("right")
            self.reset()
        elif self.position.x > WIDTH:
            self.scored.emit("left")
            self.reset()

Connect signals in the parent:

class PongGame(Node2D):
    def on_ready(self):
        # ... add paddle, ball ...
        self.scores = [0, 0]
        self.ball.scored.connect(self._on_scored)

    def _on_scored(self, side: str):
        self.scores[0 if side == "left" else 1] += 1
        # `scores` is a plain list, not a Property -> dirty by hand so the new
        # score shows. The net is static, so don't make the whole node `dynamic`.
        self.queue_redraw()

    def on_draw(self, renderer):
        renderer.draw_text(str(self.scores[0]), (WIDTH // 2 - 60, 20), scale=4, colour=(1.0, 1.0, 1.0))
        renderer.draw_text(str(self.scores[1]), (WIDTH // 2 + 40, 20), scale=4, colour=(1.0, 1.0, 1.0))

6. Polish

Add a tween on score and a timer for serve delay:

from simvx.core import tween, Timer
from simvx.core.animation.tween import ease_out_elastic

class PongGame(Node2D):
    def on_ready(self):
        # ... setup ...
        self.scale_factor = 1.0
        self.ball.scored.connect(self._on_scored)

    def _on_scored(self, side: str):
        self.scores[0 if side == "left" else 1] += 1
        # Punch the score text
        self.start_coroutine(tween(self, "scale_factor", 1.5, duration=0.1))
        self.start_coroutine(tween(self, "scale_factor", 1.0, duration=0.3, easing=ease_out_elastic))

tween() animates any property over time with optional easing. Timer fires a signal after a delay:

timer = Timer(duration=1.0, one_shot=True, autostart=True)
timer.timeout.connect(self.serve_ball)
self.add_child(timer)

7. Next Steps

The complete, runnable game is the Pong tutorial – run it with:

uv run python examples/tutorials/pong/main.py

More 2D examples to explore:

  • examples/demos/platformer.py – CharacterBody2D with gravity, jump, and platforms

  • examples/demos/asteroids2d.py – Classic arcade game with wrap-around physics

  • examples/demos/spaceinvaders2d.py – Rows of enemies, bullets, and wave progression

  • examples/features/2d/sprite.py – PNG textures as 2D quads

  • examples/features/2d/tilemap.py – GPU-rendered tilemap with camera panning

  • examples/features/2d/light.py – Point lights with shadow-casting occluders

  • examples/features/2d/navigation.py – A* pathfinding on a grid

See Examples Gallery for the full list, or Building a Simple Game with the SimVX Editor to build games visually in the editor.