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.pyIt uses the declarative
input_actions = {...}class attribute (the web-safe registration path) rather thanadd_action()inon_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
Propertystate, 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-
Propertystate (an elapsed timer, a pulsing radius, a scrolling offset, particle motion). Setdynamic = Trueon the node to opt that one node into a per-frameon_drawre-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). Callself.queue_redraw()at the site that mutates it. Prefer this overdynamicwhen 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 platformsexamples/demos/asteroids2d.py– Classic arcade game with wrap-around physicsexamples/demos/spaceinvaders2d.py– Rows of enemies, bullets, and wave progressionexamples/features/2d/sprite.py– PNG textures as 2D quadsexamples/features/2d/tilemap.py– GPU-rendered tilemap with camera panningexamples/features/2d/light.py– Point lights with shadow-casting occludersexamples/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.