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.
tools/in_container.sh pixi run example # the finished game, windowed
tools/in_container.sh pixi run example -- --headless --frames 120 --screenshot .cache/first.png1. 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:
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:
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: CStrings2. 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:
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:
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:
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):
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:
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:
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:
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):
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.
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.
tools/in_container.sh pixi run mojo run -I . -I examples tests/examples/test_first_game.mojoWhere to go next #
- Load real content:
cinder/assetsimports glTF (load_gltf,upload_gltf) andcinder/scene.spawn_gltfturns a file into entities;docs/guides/assets.md. - A walking character instead of a ball:
CharacterControllerincinder/physicsand anAnimatorwith a blend tree incinder/anim; the sample game (sample/) does both. - Sound:
AudioEnginebuses, one-shots and ECSAudioSources;docs/guides/audio.md. - Saving: make your components
SceneComponents and write the world withSceneRegistry(docs/guides/scene.md); the sample's F5/F9 save and load are forty lines. - Performance:
docs/BENCHMARKS.md, andtests/perf/test_zero_alloc.mojofor the rule that a steady-state frame allocates nothing.