The starter course
A small playable project: a third-person collection course with five pickups, a ramp, a flight of steps, a locked exit and a restart key.
A small playable project: a third-person collection course with five pickups, a ramp, a flight of steps, a locked exit and a restart key. It exists twice, byte for byte:
| Where | What it is for |
|---|---|
fixtures/starter-course/ | the project the docs, the typecheck and the play fixtures read |
crates/editor/templates/collection-course/ | what the launcher's New project → Collection course writes |
crates/editor/tests/template.rs compares the two, so neither can drift.
Contents
- Playing it
- The course
- The declared traversal range
- Behaviours
- Every tunable
- Input
- Strings
- Scenarios
- Known gaps
Playing it
cargo run -p argus -- run fixtures/starter-course/scenes/main.scene.json
Walk with WASD or the left stick, look with the mouse or the right stick. Collect the five cubes; the HUD counts them. With all five in, stand in the doorway and press E (or the pad's west face button) to open the exit. Walk through it to finish. R (or Start) restarts.
The scene names player and hud from @argus/gameplay, so those modules have to be built: pnpm --filter @argus/gameplay build, or set ARGUS_GAMEPLAY to a dist directory. An installed toolchain ships one (crates/workspace-service/src/build/tools.rs).
The course
Three levels, joined so the only way on is the obstacle. Forward is -Z, and every surface below is the top of a static box.
| Piece | Extent | Top |
|---|---|---|
| Lower floor | x ∈ [-10, 10], z ∈ [-4, 12] | y = 0 |
| Steps, six of them | x ∈ [-3, 3], z ∈ [-7.6, -4] | y = 0.25 → 1.5 |
| Step flanks (unclimbable) | x ∈ [-10, -3] and [3, 10], z ∈ [-7.6, -4] | y = 1.5 |
| Middle floor | x ∈ [-10, 10], z ∈ [-16.6, -7.6] | y = 1.5 |
| Ramp, three panels | x ∈ [-3, 3], z ∈ [-16.6, -14] | y = 1.5 → 3.0 |
| Upper floor | x ∈ [-10, 10], z ∈ [-24, -16.5] | y = 3.0 |
| Exit landing | x ∈ [-4, 4], z ∈ [-29, -24] | y = 3.0 |
Perimeter walls are 2 m high and step up with the floors. The flanks either side of the steps are 1.5 m tall, five times the controller's step offset, so the steps are the only way to the middle floor; the same is true of the lip up to the upper floor and the ramp.
Two things about the geometry are worth knowing before you edit it.
Dimensions live on transform.scale, and colliders are size: [1, 1, 1]. The world extent of a collider is size × transform.scale, so writing the metre dimensions into both squares them. One number drives the unit-cube mesh and its box collider together. This is the spelling the scene contract recommends and it is only correct on an engine that applies the scale once — which it does, since the loader stopped pre-scaling (crates/app/src/load.rs).
The ramp is three collinear panels rather than one. The static critic models an entity's walkable surface as one flat top at its bounding box's ceiling (packages/critic/src/static/reachability.ts), so a single 1.5 m ramp reads as a 1.5 m ledge and every goal above it as unreachable-goal. Three panels of 0.5 m read as three 0.5 m steps, inside the 0.6 m join the model allows. The surface a player walks on is identical: the panels are collinear and the slope is 30° throughout.
The declared traversal range
The course stays inside what the character controller is tested for, with margin. The limits are crates/physics/src/controller.rs, and fixtures/gameplay/controller-course.json is the fixture that proves them, one degree and one centimetre either side of each.
| Controller limit | This course uses | |
|---|---|---|
| Slope | 45° | 30° |
| Step | 0.30 m | 0.25 m |
| Capsule | radius 0.3 m, height 1.8 m | radius 0.35 m, height 1.8 m |
| Walk speed | 4.0 m/s default | 4.0 m/s |
Do not raise the ramp past 45° or the risers past 0.30 m. The controller refuses both by design, and the scene has no other route.
Behaviours
Five files under scenes/behaviors/, importing nothing but @argus/api types. They are transpiled by esbuild at load, so script.module names them as behaviors/<name> — a path relative to the scene's own directory (crates/app/src/modules.rs).
| File | Entity it sits on | What it does |
|---|---|---|
collectable.ts | each of the five pickups | Collected on the trigger edge or by proximity, emits one collected, sinks under the floor (sink metres, inside meta.bounds). |
course.ts | exit-volume | Counts collected against props.required, republishes the HUD line, emits goal-reached per pickup, emits course-complete on the last, and emits goal-reached for the exit when the player passes through. |
door.ts | door-volume | Shows the prompt while the player is in the doorway, and lifts props.panel when interact arrives after course-complete. |
interactor.ts | player | Turns the interact action's press edge into an interact event. |
restart.ts | player | Calls ctx.session.restart() on the restart action. |
ctx.session.restart() rebuilds the world from the scene as loaded at the end of the tick that asked: frame 0, every behaviour's state gone, the HUD cleared, and every entity back at the transform the scene gives it — which for the player is the spawn marker, because the scene keeps the two equal on purpose. The marker is what the contract asks for (spawn, for: "player", an error to leave out) and what a person reads the scene by; the runtime does not consume it, so moving one without the other would make this sentence false. Assets are not reloaded, the renderer is carried across rather than rebuilt, and the player's own key bindings — and the keys they are still holding — survive it.
Two of those placements are forced rather than chosen, and it is worth saying why.
ctx.input exists only on an entity with a controller component. The capability comes from Component::Controller and nothing else (crates/app/src/sim.rs), so a door cannot read the interact action. interactor.ts reads it on the player and puts it on the event bus; every interactable in the scene listens to the bus rather than to the device. One behaviour reads the button, any number of doors and levers react, and a test can drive the same path with POST /input without a second code path. restart.ts is on the player for the same reason.
door.ts and course.ts sit on trigger volumes, not on the thing they move. onTrigger is delivered to the volume's own script, so the volume is where the enter and leave edges are. The door lifts a separate entity, door-panel, named in props.panel; the panel is a kinematic body, so the physics layer follows the transform and the doorway is passable the tick after.
Both collectable.ts and door.ts also ask the world directly — a proximity query and a query({ inside }) — instead of trusting the trigger edge alone. That covers a player who starts inside a volume, which no edge ever reports, and a build whose sensors do not overlap a kinematic controller.
Reusing them
They are plain @argus/api behaviours and nothing in them knows about this course. To lift one into another project, copy the file into that project's scene directory and give the script component the props below. collectable.ts and door.ts in particular have no course-specific names in them: the pickup reports whatever props.goal says, and the door listens for whatever props.readyEvent says.
Three of them are also the editor's own New behaviour templates — collectable.ts as Collectable, door.ts as Trigger door and interactor.ts as Interactable — written byte for byte from the files here into scenes/behaviors/<name>.ts beside the open scene and attached to the selected entity, with a test holding the two copies to each other (crates/editor/tests/d9_behaviour.rs). See "Behaviours" in docs/EDITOR.md.
Every tunable
Every number and name below is a script.props value or a component field in scenes/main.scene.json. None of them requires touching a behaviour.
behaviors/collectable, on each pickup
| Prop | Default | This course |
|---|---|---|
goal | the entity id | pickup-1 … pickup-5 |
radius | 1.2 | 1.2 — metres for the proximity fallback |
playerTag | player | player |
event | collected | collected |
sink | 6 | 6 — metres it drops by when collected. It must land the cube below the floor it was on and still inside meta.bounds; this course's floors top out at 3 m and its bounds reach -8 m, so 6 clears every one with room to spare. The old default of 100 put the cube far outside the level, where the contract refuses to author an entity and the runtime now refuses to teleport one. |
behaviors/course, on exit-volume
| Prop | Default | This course |
|---|---|---|
required | 5 | 5 — the number of pickups. Change this and the course.progress.N strings together. |
collectEvent | collected | collected |
goalEvent | goal-reached | goal-reached — what the built-in hud listens for |
readyEvent | course-complete | course-complete |
progressField | collected | collected — the custom HUD field |
progressKey | course.progress. | course.progress. — the count is appended |
exitGoal | the entity id | exit |
completeKey | course.done | course.done |
playerTag | player | player |
menuScreen | none | main-menu — see below |
resultsScreen | none | results — see below |
resultsDelay | 0 | 1.5 — seconds between leaving by the exit and the results going up |
nextScene | none | scenes/credits.scene.json — where the exit leaves for when resultsScreen is unset; with this project's results screen the run waits for a press instead |
course.ts also keeps two pieces of state of its own: startedAt, the ctx.time.now the run began at (and again when Start is pressed), which is the clock the results screen shows; menuShown, so the start menu goes up once per run rather than once per tick; and resultsShown, resultsDue and resultsSeconds, for the results' delay and its buttons.
The two screens
The project ships ui/screens/main-menu.tsx and ui/screens/results.tsx, and the engine registers both before any behaviour's init runs. Both are modal and both take the menu input context, so while either is up nothing in the gameplay context is bound: not move, not jump, and not the restart this course's R key is. What takes one down is a button on it. The engine raises every press as a "ui-activated" event (docs/UI.md, "Hearing a press"), and course.ts answers the ones for its two screens, matching on the event's screen and id:
| press | what happens |
|---|---|
main-menu start | the menu comes down and the run begins |
main-menu restart | the run starts over, menu and all (disabled until a run has been played) |
main-menu exit | ctx.session.quit(): argus run with a window and an export exit, the editor's Play stops, a headless or managed run records session-quit and keeps running |
results retry, to-menu | the run starts over, which puts the menu back up |
With resultsScreen set, leaving by the exit no longer switches to nextScene in the same tick. The results go up resultsDelay seconds later, so the player is through the doorway and not frozen on its threshold, and the course waits for a press. main-menu.tsx marks Start data-action="dismiss" and Exit data-action="quit", which is how the UI critic's ui-no-way-back knows a screen that Escape does not dismiss still has a way off.
The menu used to be off by default, because in U1 nothing could take a modal screen down again. Both props still default to none, so a scene that sets neither starts straight into play.
What this means for a tool driving the course. The playtester presses uiActivate once at the start, and again after a relaunch; Start has focus, so that is Start (START_ACTION in packages/critic/src/play/strategy.ts). An agent on the editor's Play does the same through play.input with {"name": "uiActivate"}. ui.activate is refused on a person's Play. play.input accepts an action bound in any context of meta.input, not only the one the scene starts in. The engine tests that drive the player by keys load a copy with the two screens left out (crates/app/tests/common, starter_course_without_screens), the way the critic's play fixtures do.
scenes/credits.scene.json puts its own screen up through meta.ui.startScreen: "results": nothing drives the credits as a game, so a modal screen over them is the whole point.
behaviors/door, on door-volume
| Prop | Default | This course |
|---|---|---|
panel | none | door-panel |
openHeight | 2.1 | 2.1 — metres the panel lifts. The doorway is 2 m, so anything over 2 clears it. |
promptKey | door.prompt | door.prompt ("Press E") |
lockedKey | door.locked | door.locked |
interactEvent | interact | interact |
readyEvent | course-complete | course-complete |
openedEvent | door-opened | door-opened |
requireInteract | true | true |
playerTag | player | player |
behaviors/interactor and behaviors/restart, on player
| Prop | Default | This course |
|---|---|---|
interactor.action | interact | interact |
interactor.event | interact | interact |
restart.action | restart | restart |
restart.anytime | true | true — false waits for course-complete |
restart.readyEvent | course-complete | course-complete |
restart.event | restart-requested | restart-requested |
The built-ins, on player
| Where | Field | This course |
|---|---|---|
controller component | speed | 4.0 m/s. The player behaviour reads it from the component, so this one number drives the Rust capsule and the behaviour. |
controller component | jump | 1.2 |
controller component | crouch | false — nothing in the course needs it |
script: player | camera | { kind: "third-person", distance: 5, height: 1.6 } |
script: player | cameraRelative, facing | true, movement |
script: hud | progressField | null — course.ts owns the progress line, so the HUD does not publish a second one |
script: hud | completeKey | course.done — shown only once every goal is reached, the exit included |
What the objective line says, and when
The built-in hud behaviour shows the lowest-ordered goal not yet reached, so the line reads goal.pickup-1 ("Collect cube 1 of 5") from the first frame, moves on as each cube is collected, sits at goal.exit ("Leave through the exit") with five of six goals done, and becomes course.done ("Done, press R to restart") only after the player is out.
That sequence used to be wrong from frame 1 — course.done before a single pickup, with none of the six authored strings ever shown. The cause was entity order, and it was a trap for every project, not a fault of this scene: HudController discovers goals with a ctx.world.query({ component: "goal" }) in its init, and the runtime filled the component table one entity at a time as it wired them, so a behaviour saw only the entities written above it in the file. player sits above all six goals here. Both halves are closed — World::wire_all seats every entity's components before it wires any of them, and HudController looks again on its first update if it found nothing — and crates/app/tests/course_d4_review_probes.rs::probe_the_objective_names_the_exit_before_it_says_done pins the whole sequence, first line to last, through the runtime.
Input
meta.input is the built-in default map plus one action. A scene's meta.input replaces the whole map, so the defaults are spelled out again rather than merged.
| Action | Kind | Keyboard / mouse | Gamepad |
|---|---|---|---|
move | axis2 | W A S D | left stick |
look | axis2 | mouse delta | right stick |
jump | button | Space | south |
crouch | button | LControl | east |
sprint | button | LShift | left thumb |
interact | button | E | west |
restart | button | R | Start |
The menu context binds the three names the UI input layer reads (crates/app/src/ui/input.rs): uiNavigate (axis2: arrows, d-pad), uiActivate (Return, south) and uiBack (Escape, east). It used to bind navigate, confirm and cancel, which nothing consumes, so no key could drive a menu through the UI layer. This block is the author's map, and the only one this project uses. A player's own remapping is a separate thing: ctx.input.rebind changes one action of the live context, survives a restart, and is written to the overrides file the runtime was launched with. Nothing here calls it, because the course has no options screen.
Strings
Every user-visible string is a key into meta.strings, and the engine renders [[key]] for a key the scene does not define. There is no placeholder substitution anywhere in that path (crates/app/src/lib.rs, build_labels), so "3 of 5 collected" cannot be assembled at runtime. The progress line is therefore one key per count:
| Key | Value |
|---|---|
course.progress.0 … course.progress.5 | 0 of 5 collected … 5 of 5 collected |
course.progress.format | %s of %s collected |
course.done | Done, press R to restart |
door.prompt | Press E |
door.locked | Collect all five cubes |
goal.pickup-1 … goal.pickup-5 | Collect cube 1 of 5 … |
goal.exit | Leave through the exit |
course.progress.format is not read by anything. It holds the wording the plan asks for, so that whoever adds interpolation to the string path has it in the table already. Raising course.required means adding the matching course.progress.N key; the test suite checks the two agree.
Scenarios
packages/critic/test/starter-course.test.ts, driven through critic.play() in lockstep with seed 1 — never through the CLI, because engine play runs through packages/cli, which depends on @argus/assets. Each scenario is one scene under packages/critic/fixtures/play/starter-course/, generated from the project by generate.ts in that directory, with byte copies of the project's behaviours beside them. Copies rather than symlinks, because a symlink is not portable to a Windows checkout without developer mode; the test re-runs the generator and compares, so a copy cannot drift.
| Scenario | Fixture | What it asserts |
|---|---|---|
| a. Collect and finish | collect-all.json | All five pickups and then the exit, passed, nothing stuck, and every pickup reached before the exit. |
| b. Walls stop the player | wall-stop.json | A goal outside the west wall is not reached, the verdict is blocked or no-path, and no sampled position passes x = -10. |
| c. Slopes and steps | climb.json | Goals at the top of the steps and the top of the ramp are both reached, and the recorded path rises to 1.5 m and then 3.0 m. |
| d. Restart | restart.json | After ctx.session.restart(): the clock is back at frame 0, the player is within 0.5 m of the spawn, and the first cube is collectable a second time. |
| Interact | door-interact.json | The door stays shut when the course completes, and lifts 2.1 m on the interact press. |
| f. As shipped | scenes/main.scene.json itself | All six goals reached, door included, with the bot's interact press recorded as what got it through. |
restart.json spawns the player a metre from the first pickup, so it is collected on the opening tick with nobody steering. That is what makes the counter observable rather than only the positions: a restart that cleared per-instance state and put the cube back lets the same tick collect it a second time, and one that cleared neither emits nothing more. Scenario (d) drives a lockstep engine directly for the same reason the interact one does.
Scenarios (a), (b) and (c) go through play(). The other two do not: the bot's four recovery probes are back, strafe-left, strafe-right and jump, and it cannot send restart, so those two drive a lockstep engine directly instead. door-interact.json does too, because it measures the press itself.
The bot does press interact, once, at a door. When the four probes have not got it past what is in the way, it presses interact (if the scene binds it) and walks on (INTERACT_ACTION in packages/critic/src/play/strategy.ts). Before that, the project as it ships failed its own play test at the door, and every project made from the template started with a play/stuck it had not caused (gap 8). Scenario (f) plays the project itself, not a fixture, and holds that. collect-all.json and restart.json still set requireInteract: false, so they measure the course and not the press; wall-stop.json, climb.json and door-interact.json keep the project's own true.
The live scenarios need a real engine, and say so when they do not have one. They are it.skipIf(binary === undefined), so a checkout with no argus binary reports this suite green having skipped exactly the tests that prove the course is playable. The skip is now a block of warning lines naming every scenario it did not run, and ARGUS_REQUIRE_LIVE=1 turns the absence of a binary into a failure instead — which is what a release run sets. Build one with cargo build --release -p argus, or point ARGUS_BIN at one.
Two play() runs cannot share one engine: the second driver's start() refuses to take over a world already past its takeover frame, because a late takeover would carry a frame offset into every stamp and clip.
Known gaps
The course does not use the new rebinding API. ctx.input.rebind, bindings and resetBindings landed with ctx.session.restart and persist a player's own keys to an overrides file. This project has no options screen, so it binds through meta.input only and never calls them. Adding a rebinding screen is the obvious next thing to build on top of this course, and meta.input's table above is the list such a screen would show.
The template's tsconfig is repo-only. fixtures/starter-course/tsconfig.json points @argus/api at packages/api/src/index.ts, which only exists in a checkout, so it is not part of what New project writes. A project made from the template is type-checked by nobody: the installed editor passes --no-typecheck because it has no Talos checkout, and a type error in a behaviour surfaces as a runtime script-error.