Gem Collector¶
your first 3D game.
▶ Run in browserTags: 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())