Gem Collector

your first 3D game.

▶ Run in browser

Tags: tutorial 3d signals

Gem Collector

Your first step into 3D. The good news: almost nothing you learned changes. Nodes, input actions, on_update, and signals all work exactly the same. Three things are new, and they are what this tutorial is about: a camera you place in space, meshes with materials to make objects, and a light so you can see them.

Drive the cube around with WASD or the arrow keys and roll over the spinning gems.

Step 1: Place a camera and a light

In 2D the view was just the window. In 3D you put a Camera3D somewhere in space and aim it with look_at. And a 3D surface is black until something lights it, so add a DirectionalLight3D (a sun: parallel rays from one direction):

self.add_child(Camera3D(position=Vec3(0, 15, 15), look_at=Vec3(0, 0, 0), fov=55))
sun = self.add_child(DirectionalLight3D(position=Vec3(6, 12, 6)))
sun.intensity = 1.3

Step 2: Build objects from meshes

A visible 3D object is a MeshInstance3D: a Mesh (the shape) plus a Material (the look). The ground is a unit cube scaled wide and flat; the player is a cube; the gems are spheres:

ground = self.add_child(MeshInstance3D(mesh=Mesh.cube(1.0),
                                       material=Material(colour=(0.24, 0.27, 0.32, 1))))
ground.scale = Vec3(2 * FIELD + 2, 1.0, 2 * FIELD + 2)

Step 3: Move on the ground plane

The player reads the same input actions from Input and Movement, but now moves on the X/Z plane (Y is up in 3D), leaving height alone:

def on_update(self, dt):
    dx = Input.get_strength("move_right") - Input.get_strength("move_left")
    dz = Input.get_strength("move_down")  - Input.get_strength("move_up")
    self.position += Vec3(dx, 0, dz) * self.SPEED * dt

Step 4: Spin and collect with signals

Each Gem spins in place with rotate_y(), and when the player gets close it emits its collected signal, passing itself along:

def on_update(self, dt):
    self.rotate_y(2.5 * dt)
    if near(self.position, self._player.position):
        self.collected.emit(self)

The root connects that signal to score and respawn. The gem does not know about the score; the score does not poll the gems. That is the same decoupling you used in Nodes and Signals, now in 3D:

gem.collected.connect(self._on_collected)

def _on_collected(self, gem):
    self.score += 1
    self.hud.text = f"Score: {self.score}"
    gem.respawn()

Run it

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

What’s next

  • Monolith to Composed – the architecture pattern behind clean node design.

  • The 3D feature references – shadows, particles, physics bodies, glTF models, and more.

Source

  1"""Gem Collector: your first 3D game.
  2
  3Step into 3D. Everything you learned in 2D still applies: nodes, input actions,
  4`on_update`, and signals. What is new is the third dimension: you place a *camera*
  5in space, build objects from *meshes* with *materials*, and add a *light* so they
  6can be seen. Move the cube around the field with WASD or the arrow keys and drive
  7over the spinning gems to collect them.
  8
  9# /// simvx
 10# tags = ["tutorial", "3d", "signals"]
 11# web = { root = "GemCollector", width = 800, height = 600, responsive = true }
 12# ///
 13
 14## What you will learn
 15
 16- **Camera3D** -- position a camera in space and aim it with `look_at`.
 17- **MeshInstance3D + Mesh + Material** -- build visible objects (cube, sphere, a
 18  scaled ground) and give them colour.
 19- **DirectionalLight3D** -- 3D surfaces are black without a light.
 20- **3D movement** -- the same input actions, now moving on the X/Z ground plane.
 21- **Signals, reused** -- each gem emits `collected`; the game reacts to score and respawn.
 22
 23## How it works
 24
 25`GemCollector` (root) places a camera looking down at an angle, a directional light,
 26a flat ground, the `Player` cube, and five `Gem` spheres. Each `Gem` spins in its
 27own `on_update` and, when the player drives close enough, emits its `collected`
 28signal. The root connects that signal to bump the score and respawn the gem
 29elsewhere. The gem never touches the score; the score never polls the gems.
 30"""
 31
 32import random
 33
 34from simvx.core import (
 35    Camera3D,
 36    DirectionalLight3D,
 37    Input,
 38    Key,
 39    Material,
 40    Mesh,
 41    MeshInstance3D,
 42    Node,
 43    Signal,
 44    Text2D,
 45    Vec2,
 46    Vec3,
 47)
 48from simvx.graphics import App
 49
 50FIELD = 11.0  # half-width of the square play area
 51PICKUP = 1.3  # how close (XZ) the player must be to collect a gem
 52
 53
 54class Player(MeshInstance3D):
 55    """A cube that moves on the ground plane from the named input actions."""
 56
 57    SPEED = 8.0
 58
 59    def __init__(self, **kwargs):
 60        super().__init__(mesh=Mesh.cube(1.0), material=Material(colour=(0.3, 0.7, 1.0, 1.0), roughness=0.4), **kwargs)
 61
 62    def on_update(self, dt: float):
 63        dx = Input.get_strength("move_right") - Input.get_strength("move_left")
 64        dz = Input.get_strength("move_down") - Input.get_strength("move_up")
 65        self.position += Vec3(dx, 0.0, dz) * self.SPEED * dt
 66        self.position.x = max(-FIELD, min(FIELD, self.position.x))
 67        self.position.z = max(-FIELD, min(FIELD, self.position.z))
 68
 69
 70class Gem(MeshInstance3D):
 71    """A spinning sphere that announces when it is collected, then is respawned."""
 72
 73    def __init__(self, player: Player, **kwargs):
 74        super().__init__(mesh=Mesh.sphere(0.5), material=Material(colour=(1.0, 0.84, 0.2, 1.0), roughness=0.25), **kwargs)
 75        self._player = player
 76        self.collected = Signal()
 77        self.respawn()
 78
 79    def respawn(self):
 80        # Land somewhere on the field, but not on top of the player.
 81        while True:
 82            self.position = Vec3(random.uniform(-FIELD, FIELD), 0.6, random.uniform(-FIELD, FIELD))
 83            if (self.position.x - self._player.position.x) ** 2 + (self.position.z - self._player.position.z) ** 2 > 9.0:
 84                return
 85
 86    def on_update(self, dt: float):
 87        self.rotate_y(2.5 * dt)  # spin in place
 88        dx = self.position.x - self._player.position.x
 89        dz = self.position.z - self._player.position.z
 90        if dx * dx + dz * dz < PICKUP * PICKUP:
 91            self.collected.emit(self)  # tell the game which gem was collected
 92
 93
 94class GemCollector(Node):
 95    """Root: builds the 3D scene and wires gem signals to the score."""
 96
 97    input_actions = {
 98        "move_left": [Key.A, Key.LEFT],
 99        "move_right": [Key.D, Key.RIGHT],
100        "move_up": [Key.W, Key.UP],
101        "move_down": [Key.S, Key.DOWN],
102    }
103
104    def on_ready(self):
105        # A camera looking down at the field from one corner, and a sun-like light
106        # angled from above so the surfaces facing the camera are lit.
107        self.add_child(Camera3D(position=Vec3(0, 15, 15), look_at=Vec3(0, 0, 0), fov=55.0))
108        sun = self.add_child(DirectionalLight3D(position=Vec3(6, 12, 6)))
109        sun.intensity = 1.3
110        sun.colour = (1.0, 0.97, 0.9)
111
112        # A flat ground: a unit cube scaled wide and thin.
113        ground = self.add_child(MeshInstance3D(mesh=Mesh.cube(1.0), material=Material(colour=(0.24, 0.27, 0.32, 1.0), roughness=0.9)))
114        ground.position = Vec3(0, -0.5, 0)
115        ground.scale = Vec3(2 * FIELD + 2, 1.0, 2 * FIELD + 2)
116
117        self.player = self.add_child(Player(position=Vec3(0, 0.5, 0)))
118
119        self.score = 0
120        for _ in range(5):
121            gem = self.add_child(Gem(self.player))
122            gem.collected.connect(self._on_collected)
123
124        self.hud = self.add_child(Text2D(text="Score: 0", position=Vec2(20, 20), font_scale=2.0, colour=(1, 1, 1, 1)))
125
126    def _on_collected(self, gem: Gem):
127        # The signal carried the gem that fired it: score it and send it elsewhere.
128        self.score += 1
129        self.hud.text = f"Score: {self.score}"
130        gem.respawn()
131
132
133if __name__ == "__main__":
134    App(title="Gem Collector", width=800, height=600).run(GemCollector())