Writing a gamepreview
Build a small game from scratch and learn each method the player calls on your actor.
This tutorial builds Drift: a cube on a floor that you steer left and right with the A and D keys. It's small enough to read in one sitting, and it uses every method a game actor must implement.
You need the game tools installed and configured.
1. Create the project#
drift/
game.yaml
src/Main.mo
assets/
cube.model.yaml ground.model.yaml
blue.material.yaml grey.material.yaml
controls.ui.yaml graphics.yaml
click.wav
format_version: 1
project:
id: com.example.drift
title: Drift
version: "0.1.0"
sdk_abi: 1
entry: src/Main.mo
asset_roots: [assets]
simulation_hz: 30
The shapes and colours:
version: 1
primitive:
shape: cuboid
size: [1, 1, 1]
version: 1
primitive:
shape: cuboid
size: [12, 0.2, 6]
version: 1
base_color_linear: [0.05, 0.3, 0.9, 1]
metallic: 0.1
roughness: 0.5
emissive_linear: [0.0, 0.0, 0.0]
version: 1
base_color_linear: [0.02, 0.02, 0.025, 1]
metallic: 0.0
roughness: 0.8
emissive_linear: [0.0, 0.0, 0.0]
version: 1
bloom: false
shadows: true
The controls: one slider (steer), driven by the A and D keys, a button that recenters the cube, and a text label:
version: 1
title: Drift
labels:
- id: position
fallback: "x = 0.0 m"
buttons:
- id: center
label: Center
action: center
key: space
axes:
- id: steer
action: steer
range: [-5000, 5000]
initial: 0
negative_key: a
positive_key: d
units_per_second: 4000
2. Give each asset an id and a name#
Every asset needs a .meta.yaml file beside it with a permanent id. init-assets creates any that are missing. In a new project it creates one for every asset; here it found one:
moxzi-game init-assets .
cat assets/grey.material.yaml.meta.yaml
Created 1 asset metadata files; commit them with their source assets. Media conversion is not implemented.
format_version: 1
uuid: b2e5d7a2-7c0c-4e8a-a220-67fa3fd92a4a
address: null
importer: copy
importer_version: 1
kind: bytes
binding: grey_material_yaml
Edit each new file:
- Set
kindtomodel,material,audioorui. Leave settings files asbytes. - Set
bindingto the name you'll use in Motoko.binding: blueon a material becomesAssets.material_blue. - Set
addresson the two files the player looks for by address:controls.ui.yamlmust beui/main, andgraphics.yamlmust besettings/graphics. Without them the game has no controls, or default graphics settings.
Don't change uuid. Commit the .meta.yaml files with your assets.
For Drift, the finished files look like this:
format_version: 1
uuid: 11111111-0000-4000-8000-000000000003
address: material/blue
importer: copy
importer_version: 1
kind: material
binding: blue
format_version: 1
uuid: 11111111-0000-4000-8000-000000000005
address: ui/main
importer: copy
importer_version: 1
kind: ui
binding: controls
format_version: 1
uuid: 11111111-0000-4000-8000-000000000006
address: settings/graphics
importer: copy
importer_version: 1
kind: bytes
binding: graphics
bindings shows the constants your code can use:
moxzi-game bindings .
// Generated by Moxzi. Edit source metadata, not this file.
import Asset "mo:moxzi/Asset";
module {
public let asset_blue : Asset.Material = #material("11111111-0000-4000-8000-000000000003");
public let asset_controls : Asset.Ui = #ui("11111111-0000-4000-8000-000000000005");
public let asset_cube : Asset.Model = #model("11111111-0000-4000-8000-000000000001");
public let asset_graphics : Asset.Bytes = #bytes("11111111-0000-4000-8000-000000000006");
public let asset_grey : Asset.Material = #material("11111111-0000-4000-8000-000000000004");
public let asset_ground : Asset.Model = #model("11111111-0000-4000-8000-000000000002");
public let asset_sound_click : Asset.Audio = #audio("11111111-0000-4000-8000-000000000007");
public let material_blue : Nat32 = 1;
public let material_grey : Nat32 = 2;
public let mesh_cube : Nat32 = 1;
public let mesh_ground : Nat32 = 2;
};
3. Write the actor#
The rest of this step walks through src/Main.mo, one part at a time.
State#
// Game state. Positions are in millimetres, so the simulation uses whole numbers.
var x : Int = 0; // where the cube is
var target : Int = 0; // where the player is steering it
let speed : Int = 100; // millimetres per tick
Use whole numbers for anything that affects the outcome of the game. Integer arithmetic gives the same result on every device, so a saved game or a replay plays out the same way everywhere. Convert to floating point only when you send positions to the renderer.
Describe and boot#
public query func moxzi_describe() : async Clock.Description {
{ protocol = "moxzi.explicit-clock.v1"; simulation_hz = 30 }
};
public shared ({ caller }) func moxzi_boot(boot : Clock.Boot) : async () {
assert host == null;
host := ?caller;
epoch := boot.epoch;
};
moxzi_describe tells the player which protocol the game speaks and how many ticks per second it runs at. moxzi_boot is called once, when a new game starts. Record the caller: only the player that booted the game should be able to drive it. boot.seed is a random seed for games that need randomness.
Advance#
public shared ({ caller }) func moxzi_advance(next : Clock.Tick) : async (Hud.TickReply and Audio.State) {
assert host == ?caller and not suspended;
assert next.epoch == epoch and next.tick == tick + 1;
// Move one step toward the target.
if (x < target) { x := min(x + speed, target) }
else if (x > target) { x := max(x - speed, target) };
tick := next.tick;
time := next.time_ns;
// Tell the renderer what moved.
let delta = {
Render.empty(epoch, next.tick, next.time_ns) with
transforms = [(1 : Nat64, cubeAt(x))]
};
{ epoch; tick; time_ns = next.time_ns; output = Render.encode(delta); hud = ?hud(); audio = ?sounds(next.time_ns) }
};
The player calls moxzi_advance once per tick. Check that the tick is the next one, update the game, and return what changed for the renderer. Render.empty starts an empty change list; add to transforms for entities that moved, created and destroyed for entities that appeared or went away, and materials or visibility for other changes. Entity ids are your own numbers (Nat64).
hud sets the text of labels from controls.ui.yaml, and audio lists sounds to play this tick. Drift plays a click after the cube is recentered:
// Sound cue ids must keep increasing for the whole session.
var nextSound : Nat64 = 1;
var clickPending = false;
func sounds(now : Nat64) : [Audio.Event] {
if (not clickPending) return [];
clickPending := false;
let cue = Audio.play(nextSound, Assets.asset_sound_click, 0.5, now);
nextSound += 1;
[cue]
};
Audio.play takes a cue id, the sound asset, a volume from 0 to 1, and the time. Cue ids must keep increasing for the whole session, even across restarts of a match.
Input#
public shared ({ caller }) func moxzi_input(command : Input.Command) : async Hud.InputAck {
assert host == ?caller and not suspended;
assert command.epoch == epoch and command.at_tick == tick;
assert command.sequence == sequence + 1;
sequence := command.sequence;
let accepted = switch (command.action) {
case "steer" {
// Axis values arrive as decimal text, such as "-2500".
switch (parseInt(command.payload)) {
case (?mm) { target := mm; true };
case null false;
}
};
case "center" { target := 0; clickPending := true; true };
case _ false;
};
{ epoch; sequence; at_tick = tick; accepted; paused = false; output = ""; hud = ?hud() }
};
Each button or slider in controls.ui.yaml has an action name. When the player presses a button, or a slider's value changes, moxzi_input receives the action name and a payload. Buttons have an empty payload; sliders send their value as decimal text. Reply with accepted = false for commands you don't recognise.
sequence increases by one with each command. Check it, so a command can't be applied twice.
Suspend and resume#
public shared ({ caller }) func moxzi_lifecycle(event : Input.Lifecycle) : async Input.Lifecycle {
assert host == ?caller and event.epoch == epoch and event.at_tick == tick;
suspended := event.suspended;
event
};
The player calls moxzi_lifecycle when the app goes to the background and when it comes back. While suspended, the player sends no ticks or input.
Snapshot#
public query func moxzi_snapshot() : async Hud.Snapshot {
let ground : Render.Entity = {
id = 2;
mesh = Assets.mesh_ground;
material = Assets.material_grey;
transform = { Render.identity with translation = (0, -0.6, 0) };
visible = true;
};
let cube : Render.Entity = {
id = 1;
mesh = Assets.mesh_cube;
material = Assets.material_blue;
transform = cubeAt(x);
visible = true;
};
let scene = Render.snapshot(
[Assets.mesh_cube, Assets.mesh_ground],
[Assets.material_blue, Assets.material_grey],
[],
[1],
{
Render.empty(epoch, tick, time) with
created = [cube, ground];
camera = ?{
transform = { Render.identity with translation = (0, 6, 10); rotation = (-0.2588, 0, 0, 0.9659) };
fov = 0.8;
near = 0.1;
far = 100;
exposure = 1;
};
lights = [{
id = 1;
kind = #directional;
transform = { Render.identity with rotation = (-0.5, 0, 0, 0.866) };
color = (1, 1, 1);
intensity = 5000;
shadows = true;
}];
environment = ?{ id = 1; intensity = 1; rotation = 0 };
},
);
{ scene with hud = ?hud() }
};
moxzi_snapshot returns the whole scene: every entity, the camera, the lights and the environment. The player calls it at startup and after restoring a save, then applies the changes from each tick on top of it. List every mesh and material the scene uses in the first two arguments to Render.snapshot.
Pass the time of the last tick to Render.empty. If the snapshot's time doesn't match, restoring a saved game fails.
Positions are in metres. Rotations are quaternions (x, y, z, w); the camera here is tilted down by 30 degrees. Declare every environment id you use in the fourth argument to Render.snapshot; Drift and Pong both use 1.
Helpers#
func cubeAt(mm : Int) : Render.Transform {
{ Render.identity with translation = (Prim.intToFloat(mm) / 1000, 0, 0) }
};
func hud() : [Hud.TextValue] {
[{ id = "position"; text = "x = " # debug_show x # " mm" }]
};
func min(a : Int, b : Int) : Int { if (a < b) a else b };
func max(a : Int, b : Int) : Int { if (a > b) a else b };
func parseInt(bytes : Blob) : ?Int {
let ?text = Prim.decodeUtf8(bytes) else return null;
var value : Int = 0;
var negative = false;
var digits = 0;
for (c in text.chars()) {
if (c == '-' and digits == 0 and not negative) { negative := true }
else if (c >= '0' and c <= '9') {
value := value * 10 + Prim.nat32ToNat(Prim.charToNat32(c) - 48);
digits += 1;
} else return null;
};
if (digits == 0) null else ?(if negative -value else value)
};
4. Build and play#
moxzi-game check .
{
"asset_count": 7,
"asset_roots": [
"assets"
],
"entry": "src/Main.mo",
"project": {
"id": "com.example.drift",
"title": "Drift",
"version": "0.1.0"
},
"scope": "source descriptor, paths and asset identity metadata; compilation, media conversion and SDK conformance not checked"
}
check reads the project and assets without compiling. Then build, validate and play:
moxzi-game build . out/drift
moxzi-game validate out/drift > /dev/null && echo valid
valid
moxzi-player out/drift native
Press A and D to steer, and Space to recenter.
To check the simulation without opening a window:
moxzi-game run-headless out/drift 90 native
{
"execution_policy": {
"message_fuel": 20000000,
"version": 1
},
"live_actors": 1,
"loading_admissions": 0,
"scope": "unpaced explicit-clock execution; not rendering, replay or capacity evidence",
"simulation_time_ns": 3000000000,
"ticks": 90,
"wasm_timeout_ms": null
}
When something goes wrong#
| Symptom | Cause |
|---|---|
Motoko build failed … diagnostics: …/compile.log | A compile error. Open the log file it names |
field mesh_… does not exist | The asset's kind or binding in its .meta.yaml isn't set. Run moxzi-game bindings . to see the names |
invalid UI YAML: … unknown variant when playing | A key name in controls.ui.yaml isn't supported. See Controls |
| No buttons or labels appear | controls.ui.yaml.meta.yaml doesn't have address: ui/main |
lifecycle acknowledgement disconnected when restoring | The snapshot's time doesn't match the last tick, or an assert in moxzi_lifecycle failed |
validate checks the package structure, not the UI file, so some mistakes only show up when you play.