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 objectsexamples/demos/spaceinvaders3d.py– Classic arcade game with 3D meshesexamples/features/3d/lighting.py– Directional, point, and spot lightsexamples/features/3d/shadows.py– Cascade shadow maps with debug visualisationexamples/features/3d/ssao.py– Screen-space ambient occlusionexamples/features/3d/particles.py– Sub-emitters, collision, trailsexamples/features/3d/ibl.py– Image-based lighting with metallic spheresexamples/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.