# The starter course

> **Making a kart or vehicle game?** This page is the developer guide to the collection-course template. Start
> from the **Kart Starter** sample instead: its `README.md` (in the project folder) lists the vehicle settings to
> change first, and [`VEHICLES.md`](VEHICLES.md) has every vehicle field.

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

1. [Playing it](#playing-it)
2. [The course](#the-course)
3. [The declared traversal range](#the-declared-traversal-range)
4. [Behaviours](#behaviours)
5. [Every tunable](#every-tunable)
6. [Input](#input)
7. [Strings](#strings)
8. [Scenarios](#scenarios)
9. [Known gaps](#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. **`ctx.hud.set` and world labels have no placeholder substitution**
(see "The HUD" in `docs/API.md`), so "3 of 5 collected" cannot be assembled at runtime. A UI
screen's `t(key, { n, max })` does fill slots (`docs/UI.md`), but this course draws its HUD with
`ctx.hud.set`. 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 the installed
editor against the SDK's `@argus/api` declarations (`docs/SDK.md`), and a type error fails the
build with `build.typecheck`.

