DocsStart

Build your first game

This tutorial builds a small game on Cinder from nothing: a red ball you push around a lit ledge to knock six crates off the edge. It takes about an hour. The finished code is in examples/first_game/ and is compiled and run headless by tests/examples/test_first_game.mojo, so every snippet below is code that builds under the pinned toolchain. Each # Step N comment in examples/first_game/game.mojo marks where that step's code lives.

What you will use: the App runtime and Game trait (cinder/app), the ECS world and its built-in components (cinder/ecs, cinder/scene), the physics world (cinder/physics), the renderer's meshes, materials and lights (cinder/render), input actions (cinder/input) and an ImGui HUD through the raw bindings. Deeper material on each lives in docs/guides/.

0. Prerequisites #

Follow the README quick start once: the toolchain container, pixi run native, and pixi run test green. Every command below runs inside the container (tools/in_container.sh ...), on Linux x86-64.

bash
tools/in_container.sh pixi run example               # the finished game, windowed
tools/in_container.sh pixi run example -- --headless --frames 120 --screenshot .cache/first.png

1. An App and a Game #

A Cinder program is an App that owns every subsystem (window or offscreen target, GPU device, renderer, ECS world, physics, animation, audio, input, debug UI) and a Game, a struct you write that implements four methods. App.run_frame calls them in a fixed order every frame:

Method When Use it for
start(mut self, mut app) once, on the first frame loading content, spawning entities, binding input
fixed_update(mut self, mut app, dt) zero or more times per frame, at AppConfig.fixed_hz (60 Hz) anything that reads or drives physics
update(mut self, mut app, dt) once per frame, variable dt timers, camera, UI state
draw_ui(mut self, mut app) once per frame, inside an ImGui frame HUD, menus

examples/first_game/main.mojo is the whole entry point. It parses the engine options (--headless, --width, --height, ...) with parse_app_args, which hands back the arguments it did not recognise, builds the App, and loops:

mojo
var config = AppConfig(String("First game"), 1280, 720, False)
config.audio = False
var rest = parse_app_args(args, config)
var app = App(config^)
var game = FirstGame()
while app.run_frame(game):
    frames += 1
    if max_frames >= 0 and frames >= max_frames:
        break
game.shutdown(app)
_ = game^
_ = app^

The last three lines matter. Mojo destroys a value at its last use, so without the explicit order the App could be destroyed while the game still holds physics bodies that live in it. The game is ended first, then the app, always.

The game struct carries only what it needs to remember between frames:

mojo
struct FirstGame(Game):
    var shapes: List[Shape]
    var bodies: List[UInt32]
    var ball: UInt32
    var crates: List[UInt32]
    var fallen: Int
    var won: Bool
    var elapsed: Float32
    var ui: CStrings

2. A ledge, crates and a ball #

Content is entities in app.world. Visible things carry a LocalTransform (or a RigidBody, which writes the transform for you) and a MeshRenderer that names a mesh and a material registered with the renderer. Physics objects live in app.physics[] and are linked to entities by the RigidBody component.

Register the meshes and materials once. cube and plane are built-in primitives; the second add_mesh argument says whether to build LODs with meshoptimizer:

mojo
var floor_mesh = app.renderer[].add_mesh(String("ledge"), plane(LEDGE_HALF, 1), False)
var box_mesh = app.renderer[].add_mesh(String("box"), cube(0.5), True)
var grey = app.renderer[].add_material(Material(String("grey")))
var red = Material(String("red"))
red.base_color = Vec4(0.9, 0.15, 0.1, 1.0)
red.metallic = 0.2
red.roughness = 0.35
var red_id = app.renderer[].add_material(red)

The ledge is a static box in physics and a plane in the renderer. Static bodies are created inactive (the second create_body argument), on layer 0; the default LayerConfig makes layer 0 the static layer that collides with everything but itself:

mojo
var ledge_shape = app.physics[].box(Vec3(LEDGE_HALF, 0.5, LEDGE_HALF))
self.shapes.append(ledge_shape)
self.bodies.append(app.physics[].create_body(
    BodyDesc(ledge_shape, Vec3(0.0, -0.5, 0.0), MotionType.static(), 0), False))
var ledge = app.world.spawn()
app.world.add[LocalTransform](ledge, LocalTransform(Mat4.identity()))
app.world.add[MeshRenderer](ledge, MeshRenderer(floor_mesh, grey, False))

Dynamic crates share one shape (shapes are reference counted by Jolt; the game keeps the handle so it can release it at the end). A RigidBody component makes the engine copy the body's position and rotation into the entity's transform after every fixed step, interpolated to the render time:

mojo
var crate_shape = app.physics[].box(Vec3(0.5, 0.5, 0.5))
for i in range(CRATE_COUNT):
    var p = Vec3(Float32(i) * 1.2 - Float32(CRATE_COUNT - 1) * 0.6, 0.5, -2.5)
    var crate_desc = BodyDesc(crate_shape, p, MotionType.dynamic(), 1)
    crate_desc.mass = 10.0
    var body = app.physics[].create_body(crate_desc)
    var e = app.world.spawn()
    app.world.add[RigidBody](e, RigidBody(body, MotionType.dynamic(), p, Quat.identity()))
    app.world.add[MeshRenderer](e, MeshRenderer(box_mesh, wood_id, True))

The ball is the same recipe with a sphere shape. Masses are set explicitly: left at zero, Jolt derives mass from the shape's volume at the density of water, which makes a one-metre crate weigh a tonne. The crates are 10 kg (crate_desc.mass = 10.0) and the ball 20 kg. Two more BodyDesc fields tune the ball: damping so it stops rolling when you let go, and allow_sleeping = False so Jolt never puts the player's body to sleep while a force is about to be applied.

3. A sun and a camera #

Lights and cameras are entities too. A LightSource shines down its entity's -Z axis, so a directional light is placed with a look-at matrix; the last argument asks for cascaded shadow maps. The camera is the entity with an active CameraComponent; its transform is the camera's world matrix (the inverse of a view matrix):

mojo
var sun = app.world.spawn()
app.world.add[LocalTransform](sun, LocalTransform(
    Mat4.look_at(Vec3(6.0, 10.0, 4.0), Vec3.zero(), Vec3.unit_y()).inverse()))
app.world.add[LightSource](sun, LightSource(
    LightKind.directional(), Vec3(1.0, 0.95, 0.9), 3.0, 0.0, 0.0, 0.0, True))

var camera = app.world.spawn()
app.world.add[LocalTransform](camera, LocalTransform(
    Mat4.look_at(Vec3(0.0, 7.0, 9.0), Vec3(0.0, 0.0, -0.5), Vec3.unit_y()).inverse()))
app.world.add[CameraComponent](camera, CameraComponent(0.9, 0.05, True))

CameraComponent takes the vertical field of view in radians and the near plane; the renderer uses reverse-Z with an infinite far plane, so there is no far distance to pick. Image-based lighting comes from the renderer's default environment, so the scene is lit from all directions even with a single sun.

Run it now (pixi run example): the crates settle on the ledge, the ball sits in front of them, the sun casts shadows. Nothing moves yet.

4. Input as actions and axes #

Gameplay never reads keys directly. It binds named axes and actions in start, and reads them by name; the same names can be rebound at runtime (InputMap.listen_action) and saved as text (InputMap.save). Keys are SDL scancodes:

mojo
app.input.bind_axis(String("move_x"), Binding.key(KEY_D, 1.0))
app.input.bind_axis(String("move_x"), Binding.key(KEY_A, -1.0))
app.input.bind_axis(String("move_z"), Binding.key(KEY_S, 1.0))
app.input.bind_axis(String("move_z"), Binding.key(KEY_W, -1.0))
app.input.bind_axis(String("move_x"), Binding.gamepad_axis(0, 1.0))
app.input.bind_action(String("jump"), Binding.key(KEY_SPACE))
app.input.bind_action(String("restart"), Binding.key(KEY_R))

Several bindings on one axis add up, clamped to [-1, 1] for digital bindings (mouse deltas are left in pixels); a gamepad stick on the same axis just works.

5. Pushing the ball: the fixed step #

Everything that drives physics goes in fixed_update, which runs at exactly 60 Hz however fast the screen refreshes, so the game behaves the same on every machine (and the headless test can replay it). Forces are per step; impulses are instantaneous:

mojo
def fixed_update(mut self, mut app: App, dt: Float32) raises:
    if self.won:
        return
    var push = Vec3(app.input.axis(String("move_x")), 0.0, app.input.axis(String("move_z")))
    if push.length() > 1.0:
        push = push.normalized()
    app.physics[].add_force(self.ball, push * PUSH_FORCE)
    if app.input.just_pressed(String("jump")) and self.ball_grounded(app):
        app.physics[].add_impulse(self.ball, Vec3(0.0, 80.0, 0.0))
    self.fallen = 0
    for body in self.crates:
        if app.physics[].position(body).y() < FALL_Y:
            self.fallen += 1
    if self.fallen == CRATE_COUNT:
        self.won = True
    if app.physics[].position(self.ball).y() < FALL_Y:
        self.reset_ball(app)

just_pressed is true for exactly one frame's worth of fixed steps, so a held key fires once. Teleporting a body is set_position_rotation with activate = True, followed by zeroing its velocities, which is what reset_ball does.

6. Per-frame logic #

update runs once per rendered frame with the real elapsed time. It is the place for timers, camera smoothing and anything cosmetic:

mojo
def update(mut self, mut app: App, dt: Float32) raises:
    if not self.won:
        self.elapsed += dt
    if app.input.just_pressed(String("restart")):
        self.restart(app)

7. A HUD #

draw_ui is called inside an ImGui frame. The bindings are the raw dcimgui C API on app.lib.ptr(), and ImGui wants NUL-terminated C strings that stay alive for the frame, which is what CStrings does (clear it at the start of each draw_ui):

mojo
def draw_ui(mut self, mut app: App) raises:
    self.ui.clear()
    var lib = app.lib.ptr()
    lib[].ImGui_SetNextWindowPos(ImVec2(12.0, 12.0), Int32(ImGuiCond_Always))
    if lib[].ImGui_Begin(self.ui.c(String("hud")),
                         Optional[Pointer[Bool, MutUntrackedOrigin]](),
                         Int32(ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_AlwaysAutoResize
                               | ImGuiWindowFlags_NoInputs)):
        lib[].ImGui_TextUnformatted(self.ui.c(
            String(t"crates knocked off {self.fallen}/{CRATE_COUNT}   time {Int(self.elapsed)} s")))
    lib[].ImGui_End()

The engine's own overlay is on by default in windowed builds (--no-debug-ui hides it) and appears next to yours, with the frame-time graph, the entity inspector (your entities are there), the render graph and the log.

8. Cleaning up #

Shapes and bodies are owned by whoever created them. shutdown destroys the bodies and then the shapes, and main calls it before the game and the app go out of scope. The headless test checks app.physics[].body_count() == 0 afterwards, and App itself asserts that no GPU resource leaked when it is destroyed.

mojo
def shutdown(mut self, mut app: App):
    for b in self.bodies:
        app.physics[].destroy_body(b)
    for s in self.shapes:
        app.physics[].destroy_shape(s)

9. Running it headless, and testing it #

--headless renders to an offscreen texture and reads it back; --frames N stops after N frames; --screenshot PATH writes the last frame. That is how the test drives the game without a keyboard: it lines the ball up behind each crate and applies the same force the move axis would, checks that every crate drops off and the game reports won, restarts, reads a frame and checks that the GPU reported no validation errors.

bash
tools/in_container.sh pixi run mojo run -I . -I examples tests/examples/test_first_game.mojo

Where to go next #

View as MarkdownSource: docs/TUTORIAL.md