Your first 3D game

Build Gem Collector: drive a cube around a walled field and collect every gem before the clock runs out. Every snippet below is from the shipped, tested example.

A blue cube on a walled slate field, gold gems in two rings around it, the gem count and the countdown at the top

Play it in your browser first: Gem Collector. Then get your own copy and run it:

pip install --pre "simvx[examples]"
simvx examples copy tutorials_gem_collector --to gem_collector
python gem_collector/main.py

From a source checkout, prefix commands with uv run. Read the whole file on the source host.

Nodes, input actions, Properties and signals work as they did in Your first 2D game. What is new is space itself, and the physics nodes that live in it: CharacterBody3D, Area3D signals, a Timer, a follow camera, and pause and restart.

1. The field

The imports, and the three numbers that tune a round:

import math

from simvx.core import (
    Anchor,
    Area3D,
    Axis2D,
    BodyMode,
    BoxShape3D,
    Button,
    CanvasLayer,
    CharacterBody3D,
    ChaseCamera,
    CollisionShape3D,
    Colour,
    Control,
    Controller,
    DirectionalLight3D,
    Input,
    Key,
    Label,
    Material,
    Mesh,
    MeshInstance3D,
    Node,
    Panel,
    PhysicsBody3D,
    Property,
    Signal,
    SphereShape3D,
    Timer,
    TouchController,
    UpdateMode,
    Vec3,
    on_input,
)
from simvx.graphics import App

FIELD = 11.0  # half-width of the square play area
GEM_COUNT = 8
ROUND_TIME = 30.0  # seconds to collect every gem


The root is a plain Node. It declares the move action once, for every device, then builds the world in on_ready:

class GemCollector(Node):
    """Builds the field and keeps the score and the clock."""

    input_context = {"move": [Axis2D.WASD, Axis2D.ARROWS, Controller.LEFT_STICK]}
    score = Property(0)

    def on_ready(self):
        self.add_child(DirectionalLight3D(direction=(-1, -2, -1), intensity=1.3, colour=(1.0, 0.97, 0.9)))
        slate = Material(colour=(0.24, 0.27, 0.32), roughness=0.9)
        self.add_child(MeshInstance3D(mesh=Mesh.plane(2 * FIELD + 2), material=slate))
        edge = FIELD + 0.5
        for half, position in (
            ((edge, 1.0, 0.5), (0, 1.0, -edge)),
            ((edge, 1.0, 0.5), (0, 1.0, edge)),
            ((0.5, 1.0, edge), (-edge, 1.0, 0)),
            ((0.5, 1.0, edge), (edge, 1.0, 0)),
        ):
            self.add_child(wall(half, position))

  • In 3D, Y is up, so the ground is the X/Z plane.

  • A visible object is a MeshInstance3D: a Mesh for the shape (Mesh.box, Mesh.sphere, Mesh.plane) and a Material for the look. roughness runs from 0 (glossy) to 1 (matte).

  • A scene with no light of its own gets a fixed default key light, so something always shows. A DirectionalLight3D replaces it with a sun: parallel rays, so it has a direction and no position.

Each wall is a static physics body with a box for collision and a box mesh to see:

def wall(half_extents: tuple, position: tuple) -> PhysicsBody3D:
    """A static box the player slides along."""
    body = PhysicsBody3D(mode=BodyMode.STATIC, position=position)
    body.add_child(CollisionShape3D(shape=BoxShape3D(half_extents=half_extents)))
    size = Vec3(*half_extents) * 2
    body.add_child(MeshInstance3D(mesh=Mesh.box(size), material=Material(colour=(0.4, 0.45, 0.55), roughness=0.8)))
    return body

A body’s collision geometry is a CollisionShape3D child holding a shape resource (BoxShape3D, SphereShape3D). What you see is a separate MeshInstance3D child, so the two can differ.

2. The player

class Player(CharacterBody3D):
    """A cube that the `move` action slides over the ground."""

    speed = Property(8.0, range=(1, 20))

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.add_child(CollisionShape3D(shape=BoxShape3D(half_extents=(0.5, 0.5, 0.5))))
        self.add_child(MeshInstance3D(mesh=Mesh.box(1.0), material=Material(colour=(0.3, 0.7, 1.0), roughness=0.4)))

    def on_fixed_update(self, dt: float):
        move = Input.value("move")
        self.velocity = Vec3(move.x, 0.0, move.y) * self.speed
        self.move_and_slide(dt)

CharacterBody3D is a body that your code moves. Movement runs in on_fixed_update, on the fixed physics step: set velocity, then call move_and_slide(dt). It moves the body and slides it along anything solid in the way, which is why the player glides along a wall instead of sticking to it. The player never leaves the ground, so it needs no gravity.

Input.value("move") is one Vec2 from whichever device is in use, never longer than 1, so a diagonal is no faster than a straight line. Its y maps onto world Z: up on the stick is away from the camera.

3. A camera that follows

self.player = self.add_child(Player(position=(0, 0.5, 0)))
self.add_child(ChaseCamera(target=self.player, offset=Vec3(0, 12, 10), rotate_with_target=False, half_life=0.3))

ChaseCamera is a Camera3D that follows its target. offset is where it sits relative to the target; rotate_with_target=False keeps that offset fixed in the world, so the view does not swing round. half_life is the lag: the seconds it takes to close half the gap. For a camera that stays put, use a plain Camera3D(position=..., look_at=...).

4. Gems are areas

class Gem(Area3D):
    """A spinning gem that announces the player reaching it, then disappears."""

    collected = Signal()

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.add_child(CollisionShape3D(shape=SphereShape3D(radius=0.6)))
        gold = Material(colour=(1.0, 0.84, 0.2), roughness=0.25)
        self.add_child(MeshInstance3D(mesh=Mesh.sphere(0.5), material=gold))

    def on_ready(self):
        self.body_entered.connect(self.on_body_entered)

    def on_update(self, dt: float):
        self.rotate_y(2.5 * dt)

    def on_body_entered(self, body):
        if isinstance(body, Player):
            self.collected.emit()
            self.destroy()

An Area3D detects bodies without blocking them. When one enters, it emits body_entered with that body. The gem checks it is the player, calls its own collected signal and removes itself with destroy().

The root places the gems in two rings and connects every one to the same handler:

for i in range(GEM_COUNT):
    angle = i * math.tau / GEM_COUNT
    radius = FIELD * (0.4 if i % 2 else 0.8)
    gem = self.add_child(Gem(position=(math.cos(angle) * radius, 0.6, math.sin(angle) * radius)))
    gem.collected.connect(self.on_gem_collected)

The gem never touches the score, and the score never polls the gems.

5. The clock and the end of a round

A Timer counts duration seconds down and emits timeout:

self.timer = self.add_child(Timer(duration=ROUND_TIME, autostart=True))
self.timer.timeout.connect(lambda: self.end_round("Out of time"))

The HUD is two Labels on a CanvasLayer, and a TouchController for touch players (section 6):

self.hud = hud = self.add_child(CanvasLayer())
self.score_label = hud.add_child(Label(anchor=Anchor.TOP_LEFT, inset=16, font_size=28))
self.time_label = hud.add_child(Label(anchor=Anchor.TOP_RIGHT, inset=16, size=(160, 36), font_size=28))
self.add_child(TouchController(buttons=()))

The labels are refreshed every frame from score and the timer’s time_left. Collecting the last gem wins and the timeout loses:

def on_update(self, dt: float):
    self.show_hud()

def show_hud(self):
    self.score_label.text = f"Gems: {self.score} / {GEM_COUNT}"
    self.time_label.text = f"Time: {math.ceil(max(0.0, self.timer.time_left))}"

def on_gem_collected(self):
    self.score += 1
    if self.score == GEM_COUNT:
        self.end_round("You win!")

def end_round(self, message: str):
    self.show_hud()  # the last frame's numbers, since a paused root stops updating
    self.timer.stop()
    self.tree.paused = True
    screen = self.hud.add_child(EndScreen(message, update_mode=UpdateMode.ALWAYS))
    screen.restart_requested.connect(self.restart)

def restart(self):
    self.tree.paused = False
    self.tree.change_scene(GemCollector())

tree.paused = True stops every node whose update_mode resolves to PAUSABLE, which is the default, so the player and the gems freeze. The end screen is added with update_mode=UpdateMode.ALWAYS, so it alone keeps running and hearing input:

class EndScreen(Control):
    """A dimmed overlay with the result and a way to play again."""

    input_context = {"restart": [Key.R, Key.ENTER, Controller.START]}
    restart_requested = Signal()

    def __init__(self, message: str, **kwargs):
        super().__init__(anchor=Anchor.FULL_RECT, **kwargs)
        scrim = self.add_child(Panel(anchor=Anchor.FULL_RECT))
        scrim.bg_colour = Colour.rgba(0.0, 0.0, 0.0, 0.6)
        self.add_child(
            Label(message, anchor=Anchor.CENTER, inset=(0, 0, 0, 90), size=(400, 48), font_size=40, alignment="centre")
        )
        again = self.add_child(Button("Play again", anchor=Anchor.CENTER, inset=(0, 60, 0, 0), size=(200, 44)))
        again.pressed.connect(self.restart_requested.emit)

    @on_input("restart")
    def on_restart(self, _event):
        self.restart_requested.emit()

R, Enter, a pad’s Start button and the Play again button all emit restart_requested. The root unpauses and tree.change_scene swaps in a fresh GemCollector, which is a whole new round.

6. Touch

TouchController(buttons=()) draws an on-screen stick and a Start button and publishes them as a controller, so move and restart read them with no other code. It leaves the screen once a real controller is used.

Next steps