Gem Collector

your first 3D game.

▶ Run in browser

Tags: tutorial game 3d physics signals

Gem Collector

Your first 3D game. Nodes, input actions, Properties and signals work exactly as they did in 2D. What is new is space itself and the physics nodes that live in it.

Drive the cube with WASD, the arrow keys, a controller’s left stick or the on-screen pad, and collect every gem before the clock runs out. When the round ends, press R, Enter or Start, or the Play again button.

What it uses

Node

Job in this game

MeshInstance3D

Everything you see: a Mesh for the shape, a Material for the look

DirectionalLight3D

The sun. Without a light of your own the scene gets a fixed default key light

ChaseCamera

Follows the player at a fixed world offset (rotate_with_target=False)

CharacterBody3D

The player: set velocity, call move_and_slide(dt) in on_fixed_update

PhysicsBody3D

The walls, with mode=BodyMode.STATIC

CollisionShape3D

The collision geometry of every body and area

Area3D

Each gem: body_entered fires when the player touches it

Timer

The round clock: time_left for the HUD, timeout when it runs out

TouchController

An on-screen stick and Start button that move and restart read as a controller

Each gem turns body_entered into its own collected signal and removes itself. The root connects every gem to one handler that keeps the score. The last gem wins, the timer running out loses, and either one pauses the tree with tree.paused and shows an end screen that keeps running with update_mode=UpdateMode.ALWAYS.

The full walkthrough is the Your First 3D Game tutorial.

Run it

simvx examples run tutorials_gem_collector
simvx examples copy tutorials_gem_collector --to gem_collector   # an editable copy
python gem_collector/main.py

Next steps

  • Monolith to Composed: how to split a game into small nodes.

  • The 3D feature examples: shadows, particles, physics bodies, glTF models and more.

Source

  1# /// simvx
  2# tags = ["tutorial", "game", "3d", "physics", "signals"]
  3# web = { width = 800, height = 600, responsive = true }
  4# ///
  5"""Gem Collector: your first 3D game.
  6
  7Everything from the 2D tutorials still applies in 3D: nodes, input actions,
  8Properties and signals. What is new is space itself, and the physics nodes that
  9live in it. Drive the cube with WASD, the arrow keys, a controller's left stick
 10or the on-screen pad, and collect every gem before the clock runs out. When the
 11round ends, press R, Enter or Start, or the Play again button.
 12
 13## Camera, light and meshes
 14
 15A visible object is a `MeshInstance3D`: a `Mesh` for the shape (`Mesh.box`,
 16`Mesh.sphere`, `Mesh.plane`) and a `Material` for the look, with a `colour` and
 17a `roughness` (0 is glossy, 1 is matte). In 3D, Y is up, so the ground is the
 18X/Z plane. A scene with no light of its own gets a fixed default key light, so
 19something always shows; a `DirectionalLight3D` replaces it with a sun of your
 20choosing. A sun's rays are parallel, so it has a `direction` and no position.
 21
 22`ChaseCamera` is a `Camera3D` that follows a `target` node. Its `offset` is
 23where it sits relative to the target, and with `rotate_with_target=False` that
 24offset stays fixed in the world, so the view does not swing as the player
 25turns. `half_life` is the lag: the seconds it takes to close half the gap.
 26
 27## A character body moves and slides
 28
 29`Player` is a `CharacterBody3D`, a kinematic body that game code moves. Its
 30collision geometry is a `CollisionShape3D` child holding a `BoxShape3D`, and
 31its look is a separate `MeshInstance3D` child. Movement runs in
 32`on_fixed_update`, on the fixed physics step: set `velocity`, then call
 33`move_and_slide(dt)`, which moves the body and slides it along anything solid
 34in the way. The walls are `PhysicsBody3D` nodes with `mode=BodyMode.STATIC`.
 35The player never leaves the ground, so it needs no gravity.
 36
 37`Input.value("move")` is one `Vec2` from whichever device the player is using,
 38never longer than 1. Its `y` maps onto world Z: up on the stick is away from
 39the camera.
 40
 41## Areas report what enters them
 42
 43Each `Gem` is an `Area3D`, a region that detects bodies without blocking them.
 44When a body enters, the area emits `body_entered` with that body. The gem checks
 45it is the player, calls its own class-level `collected` signal and removes
 46itself with `destroy()`. The root connects every gem's `collected` to one
 47handler that keeps the score, so a gem never touches the score.
 48
 49## A Timer, and the end of a round
 50
 51A `Timer` counts `duration` seconds down and emits `timeout`; the HUD reads
 52`time_left` each frame. Collecting the last gem wins, and the timeout loses.
 53Either way the root sets `tree.paused`, which stops every node that resolves to
 54`UpdateMode.PAUSABLE` (the default), and shows an `EndScreen` built with
 55`update_mode=UpdateMode.ALWAYS`, so it alone keeps running and hearing input.
 56Restarting unpauses and calls `tree.change_scene` with a fresh `GemCollector`.
 57
 58## An on-screen pad is a controller
 59
 60`TouchController(buttons=())` draws a stick and a Start button for a touch
 61player and publishes them as a controller, so `move` (bound to
 62`Controller.LEFT_STICK`) already reads it. It leaves the screen once a real
 63controller is used.
 64"""
 65
 66import math
 67
 68from simvx.core import (
 69    Anchor,
 70    Area3D,
 71    Axis2D,
 72    BodyMode,
 73    BoxShape3D,
 74    Button,
 75    CanvasLayer,
 76    CharacterBody3D,
 77    ChaseCamera,
 78    CollisionShape3D,
 79    Colour,
 80    Control,
 81    Controller,
 82    DirectionalLight3D,
 83    Input,
 84    Key,
 85    Label,
 86    Material,
 87    Mesh,
 88    MeshInstance3D,
 89    Node,
 90    Panel,
 91    PhysicsBody3D,
 92    Property,
 93    Signal,
 94    SphereShape3D,
 95    Timer,
 96    TouchController,
 97    UpdateMode,
 98    Vec3,
 99    on_input,
100)
101from simvx.graphics import App
102
103FIELD = 11.0  # half-width of the square play area
104GEM_COUNT = 8
105ROUND_TIME = 30.0  # seconds to collect every gem
106
107
108class Player(CharacterBody3D):
109    """A cube that the `move` action slides over the ground."""
110
111    speed = Property(8.0, range=(1, 20))
112
113    def __init__(self, **kwargs):
114        super().__init__(**kwargs)
115        self.add_child(CollisionShape3D(shape=BoxShape3D(half_extents=(0.5, 0.5, 0.5))))
116        self.add_child(MeshInstance3D(mesh=Mesh.box(1.0), material=Material(colour=(0.3, 0.7, 1.0), roughness=0.4)))
117
118    def on_fixed_update(self, dt: float):
119        move = Input.value("move")
120        self.velocity = Vec3(move.x, 0.0, move.y) * self.speed
121        self.move_and_slide(dt)
122
123
124class Gem(Area3D):
125    """A spinning gem that announces the player reaching it, then disappears."""
126
127    collected = Signal()
128
129    def __init__(self, **kwargs):
130        super().__init__(**kwargs)
131        self.add_child(CollisionShape3D(shape=SphereShape3D(radius=0.6)))
132        gold = Material(colour=(1.0, 0.84, 0.2), roughness=0.25)
133        self.add_child(MeshInstance3D(mesh=Mesh.sphere(0.5), material=gold))
134
135    def on_ready(self):
136        self.body_entered.connect(self.on_body_entered)
137
138    def on_update(self, dt: float):
139        self.rotate_y(2.5 * dt)
140
141    def on_body_entered(self, body):
142        if isinstance(body, Player):
143            self.collected.emit()
144            self.destroy()
145
146
147def wall(half_extents: tuple, position: tuple) -> PhysicsBody3D:
148    """A static box the player slides along."""
149    body = PhysicsBody3D(mode=BodyMode.STATIC, position=position)
150    body.add_child(CollisionShape3D(shape=BoxShape3D(half_extents=half_extents)))
151    size = Vec3(*half_extents) * 2
152    body.add_child(MeshInstance3D(mesh=Mesh.box(size), material=Material(colour=(0.4, 0.45, 0.55), roughness=0.8)))
153    return body
154
155
156class GemCollector(Node):
157    """Builds the field and keeps the score and the clock."""
158
159    input_context = {"move": [Axis2D.WASD, Axis2D.ARROWS, Controller.LEFT_STICK]}
160    score = Property(0)
161
162    def on_ready(self):
163        self.add_child(DirectionalLight3D(direction=(-1, -2, -1), intensity=1.3, colour=(1.0, 0.97, 0.9)))
164        slate = Material(colour=(0.24, 0.27, 0.32), roughness=0.9)
165        self.add_child(MeshInstance3D(mesh=Mesh.plane(2 * FIELD + 2), material=slate))
166        edge = FIELD + 0.5
167        for half, position in (
168            ((edge, 1.0, 0.5), (0, 1.0, -edge)),
169            ((edge, 1.0, 0.5), (0, 1.0, edge)),
170            ((0.5, 1.0, edge), (-edge, 1.0, 0)),
171            ((0.5, 1.0, edge), (edge, 1.0, 0)),
172        ):
173            self.add_child(wall(half, position))
174
175        self.player = self.add_child(Player(position=(0, 0.5, 0)))
176        self.add_child(ChaseCamera(target=self.player, offset=Vec3(0, 12, 10), rotate_with_target=False, half_life=0.3))
177
178        for i in range(GEM_COUNT):
179            angle = i * math.tau / GEM_COUNT
180            radius = FIELD * (0.4 if i % 2 else 0.8)
181            gem = self.add_child(Gem(position=(math.cos(angle) * radius, 0.6, math.sin(angle) * radius)))
182            gem.collected.connect(self.on_gem_collected)
183
184        self.timer = self.add_child(Timer(duration=ROUND_TIME, autostart=True))
185        self.timer.timeout.connect(lambda: self.end_round("Out of time"))
186
187        self.hud = hud = self.add_child(CanvasLayer())
188        self.score_label = hud.add_child(Label(anchor=Anchor.TOP_LEFT, inset=16, font_size=28))
189        self.time_label = hud.add_child(Label(anchor=Anchor.TOP_RIGHT, inset=16, size=(160, 36), font_size=28))
190        self.add_child(TouchController(buttons=()))
191
192        # Example help overlay: not part of the technique shown.
193        hint = hud.add_child(Label("WASD, arrows, a stick or the pad", anchor=Anchor.TOP_LEFT, inset=(16, 56)))
194        hint.text_colour = (0.72, 0.75, 0.8, 1.0)
195
196    def on_update(self, dt: float):
197        self.show_hud()
198
199    def show_hud(self):
200        self.score_label.text = f"Gems: {self.score} / {GEM_COUNT}"
201        self.time_label.text = f"Time: {math.ceil(max(0.0, self.timer.time_left))}"
202
203    def on_gem_collected(self):
204        self.score += 1
205        if self.score == GEM_COUNT:
206            self.end_round("You win!")
207
208    def end_round(self, message: str):
209        self.show_hud()  # the last frame's numbers, since a paused root stops updating
210        self.timer.stop()
211        self.tree.paused = True
212        screen = self.hud.add_child(EndScreen(message, update_mode=UpdateMode.ALWAYS))
213        screen.restart_requested.connect(self.restart)
214
215    def restart(self):
216        self.tree.paused = False
217        self.tree.change_scene(GemCollector())
218
219
220class EndScreen(Control):
221    """A dimmed overlay with the result and a way to play again."""
222
223    input_context = {"restart": [Key.R, Key.ENTER, Controller.START]}
224    restart_requested = Signal()
225
226    def __init__(self, message: str, **kwargs):
227        super().__init__(anchor=Anchor.FULL_RECT, **kwargs)
228        scrim = self.add_child(Panel(anchor=Anchor.FULL_RECT))
229        scrim.bg_colour = Colour.rgba(0.0, 0.0, 0.0, 0.6)
230        self.add_child(
231            Label(message, anchor=Anchor.CENTER, inset=(0, 0, 0, 90), size=(400, 48), font_size=40, alignment="centre")
232        )
233        again = self.add_child(Button("Play again", anchor=Anchor.CENTER, inset=(0, 60, 0, 0), size=(200, 44)))
234        again.pressed.connect(self.restart_requested.emit)
235
236    @on_input("restart")
237    def on_restart(self, _event):
238        self.restart_requested.emit()
239
240
241if __name__ == "__main__":
242    App(title="Gem Collector", width=800, height=600).run(GemCollector())