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(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())