Your First 3D Game

The runnable starter is the Gem Collector tutorial (examples/tutorials/gem_collector/, playable in the examples gallery): a cube that collects spinning gems, teaching the 3D essentials – Camera3D, MeshInstance3D + Mesh + Material, DirectionalLight3D, moving on the ground plane, and signals.

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

Going further, the walkthrough below builds a more involved 3D game from scratch – an asteroid dodger with physics-body collision, a spawn timer, and post-processing – on top of those same basics.

1. A Cube in Space

from simvx.core import Camera3D, Material, Mesh, MeshInstance3D, Node, Vec3
from simvx.graphics import App

class Game(Node):
    def on_ready(self):
        cam = self.add_child(Camera3D(name="Camera", position=Vec3(0, 12, 15)))
        cam.look_at(Vec3(0, 0, -5))
        cam.fov = 60.0

        self.player = self.add_child(MeshInstance3D(name="Player"))
        self.player.mesh = Mesh.cube(size=0.5)
        self.player.material = Material(colour=(0, 1, 0, 1))

App(title="Asteroid Dodger", width=1024, height=768).run(Game())

Camera3D defines the viewpoint – look_at() orients it toward a target. MeshInstance3D renders geometry; use the Mesh.cube() / Mesh.sphere() / Mesh.cylinder() factories for built-in primitives, or Mesh.from_obj() to load a Wavefront OBJ. Material(colour=) sets the surface colour as RGBA.

2. Move the Player

from simvx.core import Camera3D, Input, InputMap, Key, Material, Mesh, MeshInstance3D, Node, Vec3
from simvx.graphics import App

class Game(Node):
    def on_ready(self):
        InputMap.add_action("left", [Key.A, Key.LEFT])
        InputMap.add_action("right", [Key.D, Key.RIGHT])
        InputMap.add_action("forward", [Key.W, Key.UP])
        InputMap.add_action("back", [Key.S, Key.DOWN])

        cam = self.add_child(Camera3D(name="Camera", position=Vec3(0, 12, 15)))
        cam.look_at(Vec3(0, 0, -5))
        cam.fov = 60.0

        self.player = self.add_child(MeshInstance3D(name="Player", position=Vec3(0, 1, 0)))
        self.player.mesh = Mesh.cube(size=0.5)
        self.player.material = Material(colour=(0, 1, 0, 1))

    def on_update(self, dt: float):
        move = Input.get_vector("left", "right", "forward", "back")
        self.player.position += Vec3(move.x, 0, move.y) * 15.0 * dt
        self.player.position.x = max(-20, min(20, self.player.position.x))
        self.player.position.z = max(-20, min(20, self.player.position.z))

App(title="Asteroid Dodger", width=1024, height=768).run(Game())

Input.get_vector() returns a normalised Vec2 from four action names (left, right, up, down). We map it onto the XZ plane for top-down movement and clamp to a play area.

Register input actions inside the root node’s on_ready(): actions registered at module scope are silently dropped when the game is exported to the web.

3. Spawn Asteroids

Add a timer that spawns falling cubes:

import math
import random
from simvx.core import Timer, Vec3

class Asteroid(MeshInstance3D):
    fall_speed = 8.0

    def on_ready(self):
        self.mesh = Mesh.cube(size=1.0)
        self.material = Material(colour=(1, 0.2, 0.2, 1))

    def on_update(self, dt: float):
        self.position.y -= self.fall_speed * dt
        self.rotate_x(math.radians(180) * dt)  # 180°/sec
        if self.position.y < -15:
            self.destroy()

In the Game.on_ready() method, add a spawn timer:

timer = Timer(duration=2.0, one_shot=False, autostart=True)
timer.timeout.connect(self._spawn_asteroid)
self.add_child(timer)

def _spawn_asteroid(self):
    x = random.uniform(-20, 20)
    z = random.uniform(-20, 20)
    self.add_child(Asteroid(name="Asteroid", position=Vec3(x, 20, z)))

Timer fires its timeout signal at regular intervals. destroy() removes a node and all its children from the tree.

4. Collision

Upgrade the player and asteroids to physics bodies with collision shapes:

from simvx.core import CharacterBody3D, CollisionShape3D

class Player(CharacterBody3D):
    speed = 15.0

    def on_ready(self):
        self.collision = self.add_child(CollisionShape3D(name="Collision", radius=0.5))
        mesh = self.add_child(MeshInstance3D(name="Mesh"))
        mesh.mesh = Mesh.cube(size=0.5)
        mesh.material = Material(colour=(0, 1, 0, 1))

    def on_update(self, dt: float):
        move = Input.get_vector("left", "right", "forward", "back")
        self.velocity = Vec3(move.x, 0, move.y) * self.speed
        self.move_and_slide(dt)


class Asteroid(MeshInstance3D):
    fall_speed = 8.0

    def on_ready(self):
        self.mesh = Mesh.cube(size=1.0)
        self.material = Material(colour=(1, 0.2, 0.2, 1))
        self.collision = self.add_child(CollisionShape3D(name="Collision", radius=0.7))

    def on_update(self, dt: float):
        self.position.y -= self.fall_speed * dt
        self.rotate_x(math.radians(180) * dt)
        if self.position.y < -15:
            self.destroy()

Check collisions in the game’s on_update():

for asteroid in self.find_all(Asteroid):
    if self.player.collision.overlaps(asteroid.collision):
        self.game_over = True

CharacterBody3D provides move_and_slide(dt) for physics-based movement. CollisionShape3D(radius=) creates a sphere collider. overlaps() checks intersection between two shapes.

5. Score and HUD

Use Text2D for screen-space text:

from simvx.core import Text2D, Vec2

class Game(Node):
    def on_ready(self):
        # ... camera, player, timer ...
        self.score = 0
        self.elapsed = 0.0
        self.score_text = self.add_child(
            Text2D(name="Score", text="Score: 0", position=Vec2(20, 20), font_scale=2.0, colour=(1, 1, 1, 1))
        )
        self.game_over_text = self.add_child(
            Text2D(name="GameOver", text="", position=Vec2(400, 350), font_scale=3.0, colour=(1, 0, 0, 1))
        )

    def on_update(self, dt: float):
        if self.game_over:
            self.game_over_text.text = f"GAME OVER! Score: {self.score}"
            return
        self.elapsed += dt
        self.score = int(self.elapsed)
        self.score_text.text = f"Score: {self.score}"

Text2D renders text as a 2D overlay. Set position for screen placement, font_scale for size, and colour for colour (RGBA floats, 0 to 1 range).

6. Polish

Add post-processing with WorldEnvironment:

from simvx.core import WorldEnvironment

class Game(Node):
    def on_ready(self):
        # ... game setup ...
        env = self.add_child(WorldEnvironment())
        env.bloom_enabled = True
        env.bloom_threshold = 0.8
        env.ssao_enabled = True
        env.fog_enabled = True
        env.fog_density = 0.02
        env.fog_colour = (0.05, 0.05, 0.15)

WorldEnvironment is the canonical way to configure rendering effects. The renderer reads its properties each frame – no direct renderer access needed.

2D in HDR

When post-processing is on, world-space 2D (sprites, shapes, particles) is composited inside the HDR pass before tonemap, so it receives the same exposure, tonemap and bloom as the 3D scene – a bright 2D shape blooms like an emissive surface, and 2D darkens/brightens with the scene as you change tonemap_exposure. Screen-space 2D (HUD/UI such as Text2D, anything screen_space=True) stays after tonemap at its exact authored colour.

Override per node (or per CanvasLayer) with the hdr property: None (default) picks the lane by role, True forces a node into the HDR lane, False keeps it flat LDR (the escape hatch for stylised 2D):

sprite.hdr = False   # keep this sprite's authored colour, ignore tonemap/bloom

See examples/features/3d/world_2d_in_hdr.py. Works on both desktop and web (the per-node hdr override is desktop-only for now).

Effects in a pure-2D game

A 2D-only game (no 3D) gets the same post effects by adding a WorldEnvironment and enabling one. Post is opt-in: a scene with no WorldEnvironment (or one with no effect enabled) renders flat at zero cost. Enable an effect and the 2D enters the HDR/post path with a linear tonemap (so flat art keeps its authored colour while bright shapes glow):

env = self.add_child(WorldEnvironment())
env.bloom_enabled   = True
env.bloom_threshold = 0.6     # < 1.0 makes ordinary bright sprites glow
env.vignette_enabled = True   # vignette / film grain / chromatic aberration / LUT / FXAA all apply too
env.crt_enabled      = True   # CRT scanlines (crt_intensity)
env.pixelate_enabled = True   # mosaic blocks (pixelate_size, px)
env.blur_enabled     = True   # box blur (blur_radius, texels)

Any screen-space effect (bloom, vignette, film grain, chromatic aberration, colour-grade LUT, FXAA, CRT/scanlines, pixelate, blur) works on 2D; depth/3D-only effects (SSAO, DoF, motion blur, fog) do not. See examples/features/2d/bloom.py and examples/features/2d/screen_effects.py. All of these ship on both backends (desktop Vulkan + web WebGPU).

7. Next Steps

More 3D examples to explore:

  • examples/demos/asteroids3d.py – Top-down arcade game with 3D objects

  • examples/demos/spaceinvaders3d.py – Classic arcade game with 3D meshes

  • examples/features/3d/lighting.py – Directional, point, and spot lights

  • examples/features/3d/shadows.py – Cascade shadow maps with debug visualisation

  • examples/features/3d/ssao.py – Screen-space ambient occlusion

  • examples/features/3d/particles.py – Sub-emitters, collision, trails

  • examples/features/3d/ibl.py – Image-based lighting with metallic spheres

  • examples/features/3d/model_viewer.py – Load glTF models with orbit camera

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