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())
sun.direction = Vec3(-1, -2, -1)
sun.intensity = 1.3

Unlike the camera, a directional light has no meaningful position: its rays are parallel and infinitely distant, so all that matters is direction, the way the light travels. The camera sits at positive Z, and these rays travel downwards and away from it, so they land on the ground and on the faces that point back at the camera, which are exactly the ones you can see.

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. This time, instead of subtracting opposite actions by hand, use Input.get_vector, which reads all four at once and clamps the result to length 1 so diagonals are not faster:

def on_update(self, dt):
    move = Input.get_vector("move_left", "move_right", "move_up", "move_down")
    self.position += Vec3(move.x, 0, move.y) * self.SPEED * dt

get_vector returns a Vec2, so its y becomes the world Z here: on the ground plane, “up” on the input pad means “forwards” in the world.

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. Closeness is a flat X/Z distance test, compared squared so there is no square root to take:

def on_update(self, dt):
    self.rotate_y(2.5 * dt)
    dx = self.position.x - self._player.position.x
    dz = self.position.z - self._player.position.z
    if dx * dx + dz * dz < PICKUP * PICKUP:
        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

# In your own copy of this directory
python main.py

# From the root of a repository checkout
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; a sun is aimed by
 20  `direction`, not placed by position.
 21- **3D movement** -- the same input actions, now moving on the X/Z ground plane.
 22- **Signals, reused** -- each gem emits `collected`; the game reacts to score and respawn.
 23
 24## How it works
 25
 26`GemCollector` (root) places a camera looking down at an angle, a directional light,
 27a flat ground, the `Player` cube, and five `Gem` spheres. Each `Gem` spins in its
 28own `on_update` and, when the player drives close enough, emits its `collected`
 29signal. The root connects that signal to bump the score and respawn the gem
 30elsewhere. The gem never touches the score; the score never polls the gems.
 31
 32Run: uv run python examples/tutorials/gem_collector/main.py
 33Headless self-check: uv run python examples/tutorials/gem_collector/main.py --test
 34"""
 35
 36import random
 37
 38from simvx.core import (
 39    Camera3D,
 40    DirectionalLight3D,
 41    Input,
 42    Key,
 43    Material,
 44    Mesh,
 45    MeshInstance3D,
 46    Node,
 47    Signal,
 48    Text2D,
 49    Vec2,
 50    Vec3,
 51)
 52from simvx.graphics import App
 53
 54FIELD = 11.0  # half-width of the square play area
 55PICKUP = 1.3  # how close (XZ) the player must be to collect a gem
 56CLEARANCE = 3.0  # a respawned gem lands at least this far from the player
 57
 58
 59class Player(MeshInstance3D):
 60    """A cube that moves on the ground plane from the named input actions."""
 61
 62    SPEED = 8.0
 63
 64    def __init__(self, **kwargs):
 65        super().__init__(
 66            mesh=Mesh.cube(1.0),
 67            material=Material(colour=(0.3, 0.7, 1.0, 1.0), roughness=0.4),
 68            **kwargs,
 69        )
 70
 71    def on_update(self, dt: float):
 72        # get_vector reads four actions at once and clamps the result to length 1,
 73        # so holding two keys diagonally is not faster than holding one.
 74        move = Input.get_vector("move_left", "move_right", "move_up", "move_down")
 75        self.position += Vec3(move.x, 0.0, move.y) * self.SPEED * dt
 76        self.position.x = max(-FIELD, min(FIELD, self.position.x))
 77        self.position.z = max(-FIELD, min(FIELD, self.position.z))
 78
 79
 80class Gem(MeshInstance3D):
 81    """A spinning sphere that announces when it is collected, then is respawned."""
 82
 83    def __init__(self, player: Player, **kwargs):
 84        super().__init__(
 85            mesh=Mesh.sphere(0.5),
 86            material=Material(colour=(1.0, 0.84, 0.2, 1.0), roughness=0.25),
 87            **kwargs,
 88        )
 89        self._player = player
 90        self.collected = Signal()
 91        self.respawn()
 92
 93    def respawn(self):
 94        # Land somewhere on the field, but not on top of the player.
 95        while True:
 96            self.position = Vec3(random.uniform(-FIELD, FIELD), 0.6, random.uniform(-FIELD, FIELD))
 97            dx = self.position.x - self._player.position.x
 98            dz = self.position.z - self._player.position.z
 99            if dx * dx + dz * dz > CLEARANCE * CLEARANCE:
100                return
101
102    def on_update(self, dt: float):
103        self.rotate_y(2.5 * dt)  # spin in place
104        dx = self.position.x - self._player.position.x
105        dz = self.position.z - self._player.position.z
106        if dx * dx + dz * dz < PICKUP * PICKUP:
107            self.collected.emit(self)  # tell the game which gem was collected
108
109
110class GemCollector(Node):
111    """Root: builds the 3D scene and wires gem signals to the score."""
112
113    input_actions = {
114        "move_left": [Key.A, Key.LEFT],
115        "move_right": [Key.D, Key.RIGHT],
116        "move_up": [Key.W, Key.UP],
117        "move_down": [Key.S, Key.DOWN],
118    }
119
120    def on_ready(self):
121        # A camera looking down at the field. A directional light is a sun: its rays
122        # are parallel, so it has no position, only a direction. These travel down
123        # and away from the camera, so they light the faces that point back at it.
124        self.add_child(Camera3D(position=Vec3(0, 15, 15), look_at=Vec3(0, 0, 0), fov=55.0))
125        sun = self.add_child(DirectionalLight3D())
126        sun.direction = Vec3(-1, -2, -1)
127        sun.intensity = 1.3
128        sun.colour = (1.0, 0.97, 0.9)
129
130        # A flat ground: a unit cube scaled wide and thin.
131        ground = self.add_child(
132            MeshInstance3D(mesh=Mesh.cube(1.0), material=Material(colour=(0.24, 0.27, 0.32, 1.0), roughness=0.9))
133        )
134        ground.position = Vec3(0, -0.5, 0)
135        ground.scale = Vec3(2 * FIELD + 2, 1.0, 2 * FIELD + 2)
136
137        self.player = self.add_child(Player(position=Vec3(0, 0.5, 0)))
138
139        self.score = 0
140        for _ in range(5):
141            gem = self.add_child(Gem(self.player))
142            gem.collected.connect(self._on_collected)
143
144        self.hud = self.add_child(Text2D(text="Score: 0", position=Vec2(20, 20), font_scale=2.0, colour=(1, 1, 1, 1)))
145
146    def _on_collected(self, gem: Gem):
147        # The signal carried the gem that fired it: score it and send it elsewhere.
148        self.score += 1
149        self.hud.text = f"Score: {self.score}"
150        gem.respawn()
151
152
153def _selftest() -> bool:
154    """Headless: drive the cube onto the gems with the movement keys and score.
155
156    The player is steered by holding the same four actions a player holds, chosen
157    each frame from where the nearest gem is, so the collection path runs end to
158    end: input action, movement, the gem's own proximity test, its signal, and the
159    root's handler. Where a gem respawns is checked against the rule its own
160    ``respawn`` states, and the field clamp is checked by driving into a corner.
161    """
162    import math
163
164    from simvx.core.testing import InputSimulator
165    from simvx.graphics.testing import assert_not_blank, save_png
166
167    TARGET_SCORE = 3
168    DEADZONE = 0.2  # closer than this on an axis and that key is not worth holding
169    CORNER_FROM, CORNER_FOR = 900, 400  # drive into a corner at the end and hold there
170
171    random.seed(3)
172    app = App(title="Gem Collector", width=800, height=600, visible=False)
173    scene = GemCollector(name="GemCollector")
174    sim = InputSimulator()
175    seen: dict[str, object] = {}
176    held: set[Key] = set()
177    respawns: list[tuple[float, float, float]] = []  # (x, z, distance from the player)
178
179    def hold(wanted: set[Key]) -> None:
180        for key in held - wanted:
181            sim.release_key(key)
182        for key in wanted - held:
183            sim.press_key(key)
184        held.clear()
185        held.update(wanted)
186
187    def on_frame(idx: int, _t: float) -> bool:
188        gems = [c for c in scene.children if isinstance(c, Gem)]
189        player = scene.player
190        if idx == 1:
191            seen["gems"] = len(gems)
192            seen["spin_from"] = float(gems[0].rotation_degrees.y)
193            seen["start_clearance"] = min(
194                math.dist((g.position.x, g.position.z), (player.position.x, player.position.z)) for g in gems
195            )
196            # Listen in after the root's own handler, so what is recorded is where
197            # the gem was sent, not where it was picked up.
198            for gem in gems:
199                gem.collected.connect(
200                    lambda g: respawns.append(
201                        (
202                            float(g.position.x),
203                            float(g.position.z),
204                            math.dist((g.position.x, g.position.z), (player.position.x, player.position.z)),
205                        )
206                    )
207                )
208        elif idx == 30:
209            seen["spin_to"] = float(gems[0].rotation_degrees.y)
210
211        if idx < CORNER_FROM:
212            if scene.score >= TARGET_SCORE:
213                hold(set())
214            else:
215                # Steer towards the nearest gem by holding the actions a player holds.
216                target = min(
217                    gems,
218                    key=lambda g: (g.position.x - player.position.x) ** 2 + (g.position.z - player.position.z) ** 2,
219                )
220                dx = float(target.position.x - player.position.x)
221                dz = float(target.position.z - player.position.z)
222                wanted = set()
223                if abs(dx) > DEADZONE:
224                    wanted.add(Key.D if dx > 0 else Key.A)
225                if abs(dz) > DEADZONE:
226                    wanted.add(Key.S if dz > 0 else Key.W)
227                hold(wanted)
228        elif idx == CORNER_FROM:
229            hold({Key.D, Key.S})  # straight at the far corner and stay on it
230        elif idx == CORNER_FROM + CORNER_FOR:
231            hold(set())
232            seen["corner"] = (float(player.position.x), float(player.position.z))
233            seen["score"] = scene.score
234            seen["hud"] = scene.hud.text
235        return True
236
237    total = CORNER_FROM + CORNER_FOR + 4
238    frames = app.run_headless(scene, frames=total, on_frame=on_frame, capture_frames=[total - 1])
239    assert_not_blank(frames[0])
240    save_png(frames[0], "/tmp/gem_collector_test.png")
241
242    ok = True
243
244    def check(label: str, passed: bool, detail: str) -> None:
245        nonlocal ok
246        ok = ok and passed
247        print(f"{'ok  ' if passed else 'FAIL'} {label}: {detail}")
248
249    check(
250        "the scene opens with five gems, none of them on top of the player",
251        seen["gems"] == 5 and seen["start_clearance"] >= CLEARANCE,
252        f"{seen['gems']} gems, the nearest {seen['start_clearance']:.2f} away (the rule is {CLEARANCE:.1f})",
253    )
254    check(
255        "each gem spins in its own on_update",
256        abs(seen["spin_to"] - seen["spin_from"]) > 1.0,
257        f"the first gem turned {seen['spin_from']:.1f} -> {seen['spin_to']:.1f} degrees about Y in half a second",
258    )
259    check(
260        "driving the cube onto a gem collects it, and the score is bumped by the signal",
261        seen["score"] >= TARGET_SCORE and seen["hud"] == f"Score: {seen['score']}",
262        f"{seen['score']} gems collected by driving over them; the HUD reads {seen['hud']!r}",
263    )
264    check(
265        "and every collected gem respawns on the field, clear of the player",
266        len(respawns) >= TARGET_SCORE
267        and all(abs(x) <= FIELD and abs(z) <= FIELD and d > CLEARANCE for x, z, d in respawns),
268        f"{len(respawns)} respawns, the closest landing {min(d for _, _, d in respawns):.2f} from the player",
269    )
270    check(
271        "the player stops at the edge of the field rather than driving off it",
272        abs(seen["corner"][0] - FIELD) < 0.01 and abs(seen["corner"][1] - FIELD) < 0.01,
273        f"came to rest at x={seen['corner'][0]:.2f}, z={seen['corner'][1]:.2f} on a field of {FIELD:.0f}",
274    )
275
276    print("screenshot: /tmp/gem_collector_test.png")
277    print("SELFTEST:", "PASS" if ok else "FAIL")
278    return ok
279
280
281if __name__ == "__main__":
282    import sys
283
284    if "--test" in sys.argv:
285        sys.exit(0 if _selftest() else 1)
286    App(title="Gem Collector", width=800, height=600).run(GemCollector())