Your first scenario: Crossing Drive
Day 1 of the training path.
Day 1 of the training path. By the end of it you have a scenario a trainee can drive. A car sets off on a town route with a 50 km/h limit, a stop line, a pedestrian crossing and a destination. The HUD shows speed, the limit and the next objective, and the scenario emits an event at every moment that matters. Day 2 scores those events.
- Part 1, the world (about 4 hours): the ground, the road, the car, the stop line, the crossing, the pedestrian, the buildings and the destination.
- Part 2, the rules (about 3 hours): what the scripts judge and emit, how to change them, and a self-test that drives the route the way a careful driver would.
The finished project is the Crossing Drive sample. Install it from the launcher's Samples tab and keep it open beside yours. The Linux build has no launcher: install the sample on Windows and copy the folder across, as Start here, "On Linux", says. Every block below is copied from it, less two things that only change its look: the sample's parts each name a material (gloss paint, glass, rubber, matte walls, in scenes/materials/), and it carries scenery (windows, roofs, kerbs, street lamps and trees, tagged scenery, none of them solid). Your scene drives the same without them; copy them across if you want the sample's look. The sample also carries day 2's two files, journal.json and training/crossing-drive.scorecard.json. Leave them out for now.
Before you start
- Talos is installed and you have played a sample (Start here).
- Two ways to make each step. Every step is written both ways. Use either, or both. step gives a prompt for it. It works through the Talos tools, checks its own work with the critic, and you watch the change arrive in the editor. Scene changed on disk: press Reload. Each step shows the JSON to add. A block with one entity per line pastes into the
entitieslist as it is. The file may mix that style with one value per line.- Ask your assistant. If you connected an AI assistant (Start here, "Connect your AI"), each
- By hand. Edit the project's files in any text editor. A scene is JSON. When you save it, the editor shows
- Check each step, with or without the editor. From a terminal in the project folder: wrong and where. Each step gives its N.
& "$env:LOCALAPPDATA\Argus\engines\<version>\bin\argus.exe" validate scenes\main.scene.json.argus validate scenes/main.scene.jsonreads the scene as the engine will. It saysok, N entities, or what isargus run . --headless --frames 600runs ten seconds with no window. It exits 0, or prints the script error.- The command is
arguson Linux. On Windows it isargus.exein the engine'sbinfolder:
- Expected in every run:
no navmesh (nav-undeclared: ...). This scenario needs no navigation. A run also counts one entity more than the file has: the road builds its surface as an entity of its own,route-surface. - Positions are in metres. +Y is up, and the car drives toward -Z. The route runs from z = 150 down to z = -140, then turns left.
- Play is F5, and Shift+Esc stops. Play runs the scene as it is, unsaved edits included.
Part 1: the world
1. Create the project
In the launcher: Projects ▸ New project. Choose 3D at the right of the Template heading, the Empty template, and the name My Crossing Drive, then press Create. The project opens in the editor with three entities: cam (a fixed camera), floor (100 m of grass) and spawn (where the player starts).
Open scenes/main.scene.json in a text editor. That file is the scene, and every step below changes it.
Check: ok, 3 entities.
2. Make room, and a sun
The route is about 400 m long, so the floor grows to 420 m square. It also gets the tag ground. A sun gives the scene shadows and a direction to its light. Without one, the sky alone lights it.
Ask: "Name the scene
crossing-drive. Make the floor 420 m square, keep it 0.5 m thick, and add the taggroundto it. Add a warm directional sun, high and from the front left, after the floor."
By hand:
- In
meta, set"name": "crossing-drive". Every run prints this name. - Replace the
floorentity with this one, and add thesunright after it. The order of entities does not change what a scene does, but it is part of a run's fingerprint (the self-test'sdigest), so keep the order the guide gives.
{"id": "floor", "name": "floor", "tags": ["static", "ground"], "transform": {"position": [0, -0.25, 0], "rotation": [0, 0, 0, 1], "scale": [420, 0.5, 420]}, "components": [{"type": "mesh", "asset": "cube", "color": "#587A42"}, {"type": "collider", "shape": "box", "size": [1, 1, 1]}, {"type": "rigidbody", "kind": "static"}]},
{"id": "sun", "name": "sun", "tags": [], "transform": {"position": [0, 40, 0], "rotation": [-0.38, 0.2, 0.08, 0.9], "scale": [1, 1, 1]}, "components": [{"type": "light", "kind": "directional", "color": "#FFF4E0", "intensity": 1}]}
You should see the grass reach the horizon, and shadows under things from now on. Check: ok, 4 entities.
3. The road
A road is a road component that reads its shape from a small file: the points along its centre line, its width and its painted lines. The engine builds the surface, its collider and a centre line that scripts can read.
Ask: "Add a road called
route, 8 m wide with edge and centre lines, inroads/route.road.json, along these points: [0,0,170], [0,0,40], [0,0,-50], [-5,0,-85], [-20,0,-112], [-45,0,-130], [-80,0,-139], [-135,0,-141]."
By hand: create the folder roads in the project folder (beside scenes), and in it route.road.json:
{
"widthM": 8.0,
"points": [
[
0,
0,
170
],
[
0,
0,
40
],
[
0,
0,
-50
],
[
-5,
0,
-85
],
[
-20,
0,
-112
],
[
-45,
0,
-130
],
[
-80,
0,
-139
],
[
-135,
0,
-141
]
],
"surface": {
"finish": {
"preset": "asphalt",
"grain": 0.1
}
},
"markings": {
"edge": {},
"centre": {}
}
}
Then add this entity to the scene's entities. source is the file's path from the project folder. material.friction is the grip of the road's surface: 1 is dry asphalt.
{"id": "route", "name": "route", "tags": ["ground"], "transform": {"position": [0, 0, 0], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1]}, "components": [{"type": "road", "source": "roads/route.road.json", "material": {"friction": 1.0}}]}
You should see a grey road with white lines running away from the camera. Select route, and Details shows its width and Edit points, which lets you drag the points in the viewport. Check: ok, 5 entities. If the road is missing, the file has a mistake, and argus validate names it: road-source-invalid with the key it did not expect. A run only warns and goes on without the road, so validate is the check to use.
4. The car
The car is a box with the engine's vehicle component and four wheels. The sedan preset sets its mass, engine, brakes, tyres and steering (Vehicles). The engine drives it from the actions throttle, brake and steer, so the scene declares those keys. The car's script, behaviors/drive-rules, films it with a chase camera and runs the rules. You add the script file now and read it in part 2.
Ask: "Replace the player spawn with a car: the
sedanvehicle preset on a 1.8 x 1.45 x 4.7 m box body at [0, 0.05, 150], with a blue body, a dark cabin and four cylinder wheels. Tag itplayerandtrainee. Bind W/S or Up/Down to throttle, A/D or Left/Right to steer, Space to handbrake, and the pad's triggers and left stick. Then copyscenes/behaviors/drive-rules.tsfrom the Crossing Drive sample and attach it to the car with the propslimitKmh50,stopLinestop-line,pedestrianpedestrian-1andautoHazardtrue. Copy the sample'smeta.stringstoo."
By hand:
- Delete the
spawnentity: the car becomes the player. - Make the camera a chase camera: in the
camentity, set the camera's"kind"to"third-person". The car's script points it at the car.
{"id": "cam", "name": "camera", "tags": [], "transform": {"position": [0, 3, 8], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1]}, "components": [{"type": "camera", "kind": "third-person", "fov": 60, "primary": true}]}
- In the scene's
meta, addinputand replace the emptystringswith these (the HUD's words):
"input": {
"contexts": {
"gameplay": {
"throttle": {
"kind": "axis1",
"keys": ["S", "W", "Down", "Up"],
"gamepad": "right-trigger"
},
"brake": {
"kind": "button",
"keys": [],
"gamepad": "left-trigger"
},
"steer": {
"kind": "axis1",
"keys": ["A", "D", "Left", "Right"],
"gamepad": "left-stick",
"deadzone": 0.15
},
"handbrake": {
"kind": "button",
"keys": ["Space"],
"gamepad": "right-bumper"
}
}
},
"active": "gameplay"
},
"strings": {
"objective.stop": "Stop fully at the stop line",
"objective.crossing": "Watch the crossing ahead",
"objective.destination": "Drive on to the green post",
"objective.arrived": "Arrived: run complete",
"hint.start": "W to drive, S to brake, A/D to steer",
"result.stopped": "Stopped at the line",
"result.not-stopped": "Did not stop at the line"
}
- An
axis1action lists its keys in pairs: the key for -1, then the key for +1. Sothrottleis S (-1) and W (+1) for one pair, and Down and Up for the other. - S at -1 brakes, and reverses once the car has stopped. That is why
brakebinds no key of its own, only the pad's left trigger. - In
assets, besidecube, add the meshes the car, the pedestrian and the post use.trianglesis each mesh's size, which the scene's budget counts.
"cylinder": {"kind": "glb", "path": "builtin:cylinder", "triangles": 96},
"capsule": {"kind": "glb", "path": "builtin:capsule", "triangles": 816},
"sphere": {"kind": "glb", "path": "builtin:sphere", "triangles": 1024}
- Add the car and its parts to
entities. Each part has"parent": "car", so its position is relative to the car. The vehicle finds its wheels by thewheelcomponent on its children, and tells front from rear by their z. The car'snameis the trainee's, which the instructor console shows; the scripts and the scorecard use itsid,car.
{"id": "car", "name": "Sam Taylor", "tags": ["player", "trainee"], "transform": {"position": [0, 0.05, 150], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1]}, "components": [{"type": "collider", "shape": "box", "size": [1.8, 1.45, 4.7], "center": [0, 0.87, 0]}, {"type": "rigidbody", "kind": "dynamic"}, {"type": "vehicle", "preset": "sedan"}, {"type": "script", "module": "behaviors/drive-rules", "props": {"limitKmh": 50, "stopLine": "stop-line", "pedestrian": "pedestrian-1", "autoHazard": true}}]},
{"id": "car-body", "name": "car-body", "tags": [], "transform": {"position": [0, 0.75, 0], "rotation": [0, 0, 0, 1], "scale": [1.8, 0.75, 4.6]}, "components": [{"type": "mesh", "asset": "cube", "color": "#1F4FA8"}], "parent": "car"},
{"id": "car-cabin", "name": "car-cabin", "tags": [], "transform": {"position": [0, 1.35, 0.2], "rotation": [0, 0, 0, 1], "scale": [1.6, 0.55, 2.3]}, "components": [{"type": "mesh", "asset": "cube", "color": "#1A1E24"}], "parent": "car"},
{"id": "car-wheel-fl", "name": "car-wheel-fl", "tags": [], "transform": {"position": [-0.775, 0.32, -1.4], "rotation": [0, 0, 0.7071, 0.7071], "scale": [0.64, 0.22, 0.64]}, "components": [{"type": "mesh", "asset": "cylinder", "color": "#151515"}, {"type": "wheel"}], "parent": "car"},
{"id": "car-wheel-fr", "name": "car-wheel-fr", "tags": [], "transform": {"position": [0.775, 0.32, -1.4], "rotation": [0, 0, 0.7071, 0.7071], "scale": [0.64, 0.22, 0.64]}, "components": [{"type": "mesh", "asset": "cylinder", "color": "#151515"}, {"type": "wheel"}], "parent": "car"},
{"id": "car-wheel-rl", "name": "car-wheel-rl", "tags": [], "transform": {"position": [-0.775, 0.32, 1.4], "rotation": [0, 0, 0.7071, 0.7071], "scale": [0.64, 0.22, 0.64]}, "components": [{"type": "mesh", "asset": "cylinder", "color": "#151515"}, {"type": "wheel"}], "parent": "car"},
{"id": "car-wheel-rr", "name": "car-wheel-rr", "tags": [], "transform": {"position": [0.775, 0.32, 1.4], "rotation": [0, 0, 0.7071, 0.7071], "scale": [0.64, 0.22, 0.64]}, "components": [{"type": "mesh", "asset": "cylinder", "color": "#151515"}, {"type": "wheel"}], "parent": "car"}
- Copy
scenes/behaviors/drive-rules.tsfrom the sample into your project'sscenes/behaviors/folder (make the folder).
You should see, after F5, the camera behind the car. W drives, S brakes, and A and D steer. The HUD shows Speed, Limit and Time, and "Stop fully at the stop line". Check: ok, 11 entities, and the ten-second run exits 0. Without a display, step 14's self-test is the proof that the car drives. If the car does not move, check the input block: every action needs at least one key or pad binding, and active must name the context.
5. The stop line
Three things: white paint across the road, a sign, and a trigger volume. A trigger volume is a box collider marked isTrigger, so it is not solid, and a trigger component that names the event it raises. When the car's front enters it, the car's script hears stop-line and judges the stop.
Ask: "At z = 40, add a white stop line across the road, a red stop sign on a grey post on the right, and an invisible trigger volume
stop-lineacross the road that raises the trigger eventstop-line."
By hand: add to entities:
{"id": "stop-line-paint", "name": "stop-line-paint", "tags": [], "transform": {"position": [0, 0.03, 40.0], "rotation": [0, 0, 0, 1], "scale": [7.4, 0.02, 0.45]}, "components": [{"type": "mesh", "asset": "cube", "color": "#E8E6DF", "colliderMode": "none"}]},
{"id": "stop-sign-post", "name": "stop-sign-post", "tags": [], "transform": {"position": [5.2, 1.25, 41.0], "rotation": [0, 0, 0, 1], "scale": [0.08, 2.5, 0.08]}, "components": [{"type": "mesh", "asset": "cylinder", "color": "#8A8B8E"}]},
{"id": "stop-sign", "name": "stop-sign", "tags": [], "transform": {"position": [5.2, 2.75, 41.0], "rotation": [0, 0, 0, 1], "scale": [0.8, 0.8, 0.06]}, "components": [{"type": "mesh", "asset": "cube", "color": "#B0141A"}]},
{"id": "stop-line", "name": "stop line", "tags": ["volume"], "transform": {"position": [0, 1.5, 40.0], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1]}, "components": [{"type": "collider", "shape": "box", "size": [10.0, 3, 0.5], "isTrigger": true}, {"type": "trigger", "event": "stop-line"}]}
You should see the line and the sign. Drive over the line and the objective changes to "Watch the crossing ahead". Check: ok, 15 entities.
A mesh that nothing moves collides as its own shape even with no collider (the run counts these as mesh colliders), so the car can bump the post. The paint says "colliderMode": "none": it is painted on, and the car must not bump over it. Only what part 2 tags as an obstacle counts as a collision.
6. The crossing
The crossing is six white stripes and a pavement on each side, at z = -20. Two volumes go with it. The hazard zone, 32 m before the crossing, is where the pedestrian is sent out. The crossing volume tells the rules that the car has reached the crossing.
Ask: "At z = -20, add a pedestrian crossing: six white stripes 0.6 m wide and 3 m long across the road, and a 3 m wide grey pavement on each side, 24 m long. Add trigger volumes
crossingat z = -20 andhazard-zoneat z = 12, each across the road and raising the event of its own name."
By hand: add to entities:
{"id": "crossing-stripe-1", "name": "crossing-stripe-1", "tags": [], "transform": {"position": [-3.0, 0.03, -20.0], "rotation": [0, 0, 0, 1], "scale": [0.6, 0.02, 3.0]}, "components": [{"type": "mesh", "asset": "cube", "color": "#E8E6DF", "colliderMode": "none"}]},
{"id": "crossing-stripe-2", "name": "crossing-stripe-2", "tags": [], "transform": {"position": [-1.8, 0.03, -20.0], "rotation": [0, 0, 0, 1], "scale": [0.6, 0.02, 3.0]}, "components": [{"type": "mesh", "asset": "cube", "color": "#E8E6DF", "colliderMode": "none"}]},
{"id": "crossing-stripe-3", "name": "crossing-stripe-3", "tags": [], "transform": {"position": [-0.6, 0.03, -20.0], "rotation": [0, 0, 0, 1], "scale": [0.6, 0.02, 3.0]}, "components": [{"type": "mesh", "asset": "cube", "color": "#E8E6DF", "colliderMode": "none"}]},
{"id": "crossing-stripe-4", "name": "crossing-stripe-4", "tags": [], "transform": {"position": [0.6, 0.03, -20.0], "rotation": [0, 0, 0, 1], "scale": [0.6, 0.02, 3.0]}, "components": [{"type": "mesh", "asset": "cube", "color": "#E8E6DF", "colliderMode": "none"}]},
{"id": "crossing-stripe-5", "name": "crossing-stripe-5", "tags": [], "transform": {"position": [1.8, 0.03, -20.0], "rotation": [0, 0, 0, 1], "scale": [0.6, 0.02, 3.0]}, "components": [{"type": "mesh", "asset": "cube", "color": "#E8E6DF", "colliderMode": "none"}]},
{"id": "crossing-stripe-6", "name": "crossing-stripe-6", "tags": [], "transform": {"position": [3.0, 0.03, -20.0], "rotation": [0, 0, 0, 1], "scale": [0.6, 0.02, 3.0]}, "components": [{"type": "mesh", "asset": "cube", "color": "#E8E6DF", "colliderMode": "none"}]},
{"id": "pavement-left", "name": "pavement-left", "tags": [], "transform": {"position": [-6.0, 0.06, -20.0], "rotation": [0, 0, 0, 1], "scale": [3.0, 0.12, 24.0]}, "components": [{"type": "mesh", "asset": "cube", "color": "#B4AFA6"}]},
{"id": "pavement-right", "name": "pavement-right", "tags": [], "transform": {"position": [6.0, 0.06, -20.0], "rotation": [0, 0, 0, 1], "scale": [3.0, 0.12, 24.0]}, "components": [{"type": "mesh", "asset": "cube", "color": "#B4AFA6"}]},
{"id": "crossing", "name": "crossing", "tags": ["volume"], "transform": {"position": [0, 1.5, -20.0], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1]}, "components": [{"type": "collider", "shape": "box", "size": [10.0, 3, 3], "isTrigger": true}, {"type": "trigger", "event": "crossing"}]},
{"id": "hazard-zone", "name": "hazard zone", "tags": ["volume"], "transform": {"position": [0, 1.5, 12.0], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1]}, "components": [{"type": "collider", "shape": "box", "size": [10.0, 3, 2], "isTrigger": true}, {"type": "trigger", "event": "hazard-zone"}]}
Check: ok, 25 entities.
7. The pedestrian
The pedestrian waits on the right pavement. The parent entity carries the collider and the script, and the two children are what you see: a capsule body and a head. Its collider is a trigger too, so the car passes through rather than stopping dead. The touch is reported as a collision in part 2. Its script, behaviors/pedestrian, walks it across when the event send-pedestrian arrives.
Ask: "Copy
scenes/behaviors/pedestrian.tsfrom the Crossing Drive sample. Add a pedestrianpedestrian-1(tagpedestrian) on the right pavement at [6.2, 0, -20.8], facing across the road: a kinematic body with a 0.6 x 1.8 x 0.6 trigger box that raisespedestrian-contact, an orange capsule body and a head. Attachbehaviors/pedestrianwithto[-6.2, 0, -20.8],speed1.4 andwatchcar."
By hand: copy scenes/behaviors/pedestrian.ts from the sample, then add to entities:
{"id": "pedestrian-1", "name": "pedestrian", "tags": ["pedestrian"], "transform": {"position": [6.2, 0, -20.8], "rotation": [0, 0.7071, 0, 0.7071], "scale": [1, 1, 1]}, "components": [{"type": "collider", "shape": "box", "size": [0.6, 1.8, 0.6], "center": [0, 0.9, 0], "isTrigger": true}, {"type": "trigger", "event": "pedestrian-contact"}, {"type": "rigidbody", "kind": "kinematic"}, {"type": "script", "module": "behaviors/pedestrian", "props": {"to": [-6.2, 0, -20.8], "speed": 1.4, "watch": "car"}}]},
{"id": "pedestrian-1-body", "name": "pedestrian-1-body", "tags": [], "transform": {"position": [0, 0.8, 0], "rotation": [0, 0, 0, 1], "scale": [0.45, 0.6, 0.3]}, "components": [{"type": "mesh", "asset": "capsule", "color": "#F28C0F"}], "parent": "pedestrian-1"},
{"id": "pedestrian-1-head", "name": "pedestrian-1-head", "tags": [], "transform": {"position": [0, 1.6, 0], "rotation": [0, 0, 0, 1], "scale": [0.24, 0.26, 0.24]}, "components": [{"type": "mesh", "asset": "sphere", "color": "#C68E6A"}], "parent": "pedestrian-1"}
The pedestrian moves, so it has a kinematic rigid body: a body the script moves and physics does not push. The volumes and the buildings stand still and need none.
You should see the pedestrian step out as you approach the crossing, and walk to the far pavement. Check: ok, 28 entities.
8. The destination and the street
The destination is a green post and one more volume, near the end of the road. The buildings make the drive read as a street. They are solid, and part 2 counts driving into one as a collision.
Ask: "Near the end of the road, add a 4 m tall green post at [-118, 2, -135] and a trigger volume
destinationacross the road at [-115, 1.5, -140.4] raisingdestination. Then put ten box buildings along the street, at least 8 m from the road's centre line, solid, taggedstaticandbuilding."
By hand: add to entities:
{"id": "destination-post", "name": "destination-post", "tags": [], "transform": {"position": [-118, 2, -135.0], "rotation": [0, 0, 0, 1], "scale": [0.3, 4, 0.3]}, "components": [{"type": "mesh", "asset": "cylinder", "color": "#2EA043"}]},
{"id": "destination", "name": "destination", "tags": ["volume"], "transform": {"position": [-115, 1.5, -140.4], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1]}, "components": [{"type": "collider", "shape": "box", "size": [4, 3, 12.0], "isTrigger": true}, {"type": "trigger", "event": "destination"}]}
and the buildings:
{"id": "building-1", "name": "building-1", "tags": ["static", "building"], "transform": {"position": [-13, 3.44, 120], "rotation": [0, 0, 0, 1], "scale": [10, 7, 12]}, "components": [{"type": "mesh", "asset": "cube", "color": "#B5654A"}, {"type": "collider", "shape": "box", "size": [1, 1, 1]}]},
{"id": "building-2", "name": "building-2", "tags": ["static", "building"], "transform": {"position": [13, 2.94, 104], "rotation": [0, 0, 0, 1], "scale": [9, 6, 14]}, "components": [{"type": "mesh", "asset": "cube", "color": "#D8CCB2"}, {"type": "collider", "shape": "box", "size": [1, 1, 1]}]},
{"id": "building-3", "name": "building-3", "tags": ["static", "building"], "transform": {"position": [-14, 3.94, 78], "rotation": [0, 0, 0, 1], "scale": [11, 8, 10]}, "components": [{"type": "mesh", "asset": "cube", "color": "#8E959E"}, {"type": "collider", "shape": "box", "size": [1, 1, 1]}]},
{"id": "building-4", "name": "building-4", "tags": ["static", "building"], "transform": {"position": [14, 2.94, 62], "rotation": [0, 0, 0, 1], "scale": [10, 6, 12]}, "components": [{"type": "mesh", "asset": "cube", "color": "#C9A77C"}, {"type": "collider", "shape": "box", "size": [1, 1, 1]}]},
{"id": "building-5", "name": "building-5", "tags": ["static", "building"], "transform": {"position": [-13, 2.94, 6], "rotation": [0, 0, 0, 1], "scale": [9, 6, 12]}, "components": [{"type": "mesh", "asset": "cube", "color": "#6F7F8C"}, {"type": "collider", "shape": "box", "size": [1, 1, 1]}]},
{"id": "building-6", "name": "building-6", "tags": ["static", "building"], "transform": {"position": [14, 4.44, -2], "rotation": [0, 0, 0, 1], "scale": [10, 9, 10]}, "components": [{"type": "mesh", "asset": "cube", "color": "#A9876B"}, {"type": "collider", "shape": "box", "size": [1, 1, 1]}]},
{"id": "building-7", "name": "building-7", "tags": ["static", "building"], "transform": {"position": [-14, 3.44, -44], "rotation": [0, 0, 0, 1], "scale": [10, 7, 14]}, "components": [{"type": "mesh", "asset": "cube", "color": "#C7C2B8"}, {"type": "collider", "shape": "box", "size": [1, 1, 1]}]},
{"id": "building-8", "name": "building-8", "tags": ["static", "building"], "transform": {"position": [15, 2.94, -50], "rotation": [0, 0, 0, 1], "scale": [9, 6, 12]}, "components": [{"type": "mesh", "asset": "cube", "color": "#7E6A5C"}, {"type": "collider", "shape": "box", "size": [1, 1, 1]}]},
{"id": "building-9", "name": "building-9", "tags": ["static", "building"], "transform": {"position": [-32, 3.44, -92], "rotation": [0, 0, 0, 1], "scale": [12, 7, 10]}, "components": [{"type": "mesh", "asset": "cube", "color": "#B9B4A6"}, {"type": "collider", "shape": "box", "size": [1, 1, 1]}]},
{"id": "building-10", "name": "building-10", "tags": ["static", "building"], "transform": {"position": [-62, 3.94, -116], "rotation": [0, 0, 0, 1], "scale": [10, 8, 10]}, "components": [{"type": "mesh", "asset": "cube", "color": "#94A3AE"}, {"type": "collider", "shape": "box", "size": [1, 1, 1]}]}
You should see, at the green post, "Arrived: run complete". Under it are whether you stopped at the line, how many times you went over the limit, and how many collisions you had.
That is the whole world. Check: ok, 40 entities (a run says 41: the road's surface). Save it (Ctrl+S in the editor, if you made changes there).
Part 2: the rules
Part 1 copied two scripts. This part reads them, so you can change them and write your own.
9. How a behaviour works
A behaviour is a TypeScript module whose default export has hooks. The engine calls them:
| Hook | When |
|---|---|
init(ctx) | once, when the entity is set up (again after a hot reload, with ctx.state kept) |
update(ctx, dt) | every tick, 60 a second; dt is 1/60 s |
onTrigger(ctx, t) | the entity enters or leaves a trigger volume, as either side: the car hears the volumes it drives into, and a volume's own script (the pedestrian's) hears what enters it |
onCollision(ctx, c) | the entity's body starts touching another solid body; c.other is that entity |
onEvent(ctx, ev) | any behaviour emitted an event (ctx.events.emit): ev.name, ev.data |
What the two scripts use, and what it is:
| Member | What |
|---|---|
t.event, t.entering, t.volume, t.other | in onTrigger: the volume's event name, entering (or leaving), the volume, and what entered it |
ctx.entity | this entity: .id, .tags, .distanceTo(other), .moveToward(point, metres) |
ctx.vehicle | its vehicle: .speed (m/s), .throttle (-1 to 1), .brake (0 to 1) |
ctx.world.find(id) | another entity, by id (or null) |
ctx.events.emit(name, data) | sends an event to every behaviour, and to day 2's journal |
ctx.log(name, data) | writes a line to the log: a headless run prints it |
ctx.hud.objective(key), ctx.hud.prompt(key), ctx.hud.set(field, value, options) | the HUD's objective line, its prompt, and a named field with a label and unit |
ctx.camera.follow(entity), ctx.camera.set("third-person", {...}) | the chase camera and its distance, height and lag |
ctx.props, ctx.state | the script component's props, and this instance's own state, kept across hot reloads and in recordings |
Every member is typed. Your code editor completes them from the types in the engine's sdk folder, and the docs site's Script API pages describe each one. Save a script while Play runs, and the change is live at once.
10. The car's rules: drive-rules.ts
import type { Behavior, Ctx } from "@argus/api"
/**
* Crossing Drive: the rules of the run, on the trainee's car (the scene's player entity).
*
* The engine drives the car itself (`vehicle` with `preset: "sedan"`, docs/VEHICLES.md): W / S or the triggers,
* A / D or the left stick. This script only
* - films it (a chase camera) and shows speed, the limit, the time and the next objective on the HUD,
* - judges the stop: the slowest the car went over the last `STOP_WINDOW_M` metres before the stop line,
* - sends the pedestrian out when the car enters the hazard zone (`autoHazard`; an instructor can send it instead
* with the bus event `send-pedestrian`),
* - and emits one bus event at each moment that matters, so a scorecard, a recording or a test can read the run:
*
* | Event | Data | When |
* |---|---|---|
* | `speed-over` | `{ subject, speed_kmh, limit_kmh }` | the speed goes over the limit (once until it is back under) |
* | `brake-applied` | `{ subject, speed_kmh }` | the brake goes on (once per press) |
* | `stop-line` | `{ subject, stopped, min_kmh, speed_kmh }` | the car's front reaches the stop line |
* | `crossing-entered` | `{ subject }` | the car reaches the pedestrian crossing |
* | `trainee-collision` | `{ subject, other, speed_kmh }` | the car hits an entity tagged building, pedestrian or traffic |
* | `destination-reached` | `{ subject, seconds }` | the car reaches the green post |
*
* Each is also written to the log, so a headless run prints it. The three places on the route also emit
* `goal-reached` (`stop-line`, `crossing`, `destination`): the self-test checks them.
* Change the numbers below, or the props in the scene, and press Play again.
*/
const KMH = 3.6
/** Slower than this counts as stopped, km/h. */
const STOPPED_KMH = 1
/** The stop is judged over this many metres before the line, measured from the car's front. */
const STOP_WINDOW_M = 10
/** Half the car's length, m: its front bumper is this far ahead of its centre (the collider is 4.7 m long). */
const HALF_LENGTH = 2.35
/** After a `speed-over`, the speed must fall this far under the limit before another one counts, km/h. */
const REARM_KMH = 2
/** Hitting an entity with one of these tags is a collision. Tag anything else the trainee must not hit. */
const OBSTACLES = ["building", "pedestrian", "traffic"]
interface State {
/** The clock starts on the first touch of the throttle, not at Play. */
started: boolean
seconds: number
/** The slowest the car went inside the stop window, km/h. */
approachMinKmh: number
over: boolean
overCount: number
/** The brake was held last tick. */
braking: boolean
collisions: number
/** The places already reached, so each is reported once. */
reached: string[]
stopped: boolean | null
}
interface Props {
/** The speed limit, km/h. */
limitKmh?: number
/** The stop-line volume's entity id. */
stopLine?: string
/** The pedestrian sent out at the hazard zone. */
pedestrian?: string
/** Send the pedestrian out when the car enters the hazard zone (false: only an instructor does). */
autoHazard?: boolean
}
type C = Ctx<State, Props>
/** Emits a scenario event, and logs it: a headless run (`argus run . --headless --self-test ...`) prints each one. */
function report(ctx: C, name: string, data: Record<string, unknown>) {
ctx.events.emit(name, data)
ctx.log(name, data)
}
/** True the first time `place` is reached. The three places on the route also count as goals. */
function once(ctx: C, place: string, goal = true): boolean {
if (ctx.state.reached.includes(place)) return false
ctx.state.reached.push(place)
if (goal) ctx.events.emit("goal-reached", { goal: place })
return true
}
/** Braking: the brake held, or the throttle pulled back (S) while still rolling forward. */
function braking(ctx: C): boolean {
const v = ctx.vehicle
return v !== null && (v.brake > 0.1 || (v.throttle < -0.1 && v.speed > 0.5))
}
function kmh(ctx: C): number {
return Math.abs(ctx.vehicle?.speed ?? 0) * KMH
}
function collided(ctx: C, other: string) {
ctx.state.collisions += 1
report(ctx, "trainee-collision", { subject: ctx.entity.id, other, speed_kmh: round1(kmh(ctx)) })
}
function round1(x: number): number {
return Math.round(x * 10) / 10
}
export default {
init(ctx) {
const s = ctx.state
// `init` runs again after a hot reload with `state` kept: fill only what is missing.
if (typeof s.seconds !== "number") {
s.started = false
s.seconds = 0
s.approachMinKmh = Infinity
s.over = false
s.overCount = 0
s.braking = false
s.collisions = 0
s.reached = []
s.stopped = null
}
ctx.camera.follow(ctx.entity)
ctx.camera.set("third-person", { distance: 8, pitch: -0.22, eyeHeight: 1.6, recenterDelay: 0, lookAhead: 0.3 })
ctx.hud.objective(s.reached.includes("stop-line") ? "objective.destination" : "objective.stop")
ctx.hud.set("limit", ctx.props.limitKmh ?? 50, { label: "Limit", unit: "km/h" })
if (!s.started) ctx.hud.prompt("hint.start")
},
update(ctx, dt) {
const s = ctx.state
const v = ctx.vehicle
if (v === null) return
const limit = ctx.props.limitKmh ?? 50
const speed = kmh(ctx)
const done = s.reached.includes("destination")
if (!s.started && Math.abs(v.throttle) > 0.05) {
s.started = true
ctx.hud.prompt(null)
}
if (s.started && !done) s.seconds += dt
// The stop: the slowest speed while the car's front is within STOP_WINDOW_M of the line.
if (!s.reached.includes("stop-line")) {
const line = ctx.world.find(ctx.props.stopLine ?? "stop-line")
if (line !== null && ctx.entity.distanceTo(line) - HALF_LENGTH <= STOP_WINDOW_M) {
s.approachMinKmh = Math.min(s.approachMinKmh, speed)
}
}
// The brake: one event per press, so a reaction time can be measured from the pedestrian stepping out.
const brake = braking(ctx)
if (brake && !s.braking) report(ctx, "brake-applied", { subject: ctx.entity.id, speed_kmh: round1(speed) })
s.braking = brake
// Over the limit: once per excursion.
if (!s.over && speed > limit) {
s.over = true
s.overCount += 1
report(ctx, "speed-over", { subject: ctx.entity.id, speed_kmh: round1(speed), limit_kmh: limit })
} else if (s.over && speed < limit - REARM_KMH) {
s.over = false
}
ctx.hud.set("speed", Math.round(speed), { label: "Speed", unit: "km/h" })
ctx.hud.set("time", s.seconds, { label: "Time", unit: "s", decimals: 1 })
},
onTrigger(ctx, t) {
if (!t.entering) return
const s = ctx.state
switch (t.event) {
case "stop-line":
if (!once(ctx, "stop-line")) return
s.stopped = s.approachMinKmh < STOPPED_KMH
// min_kmh is what the stop is judged on (the slowest in the window); speed_kmh is the speed crossing the line.
report(ctx, "stop-line", {
subject: ctx.entity.id,
stopped: s.stopped,
min_kmh: round1(Number.isFinite(s.approachMinKmh) ? s.approachMinKmh : kmh(ctx)),
speed_kmh: round1(kmh(ctx)),
})
ctx.hud.objective("objective.crossing")
return
case "hazard-zone":
if (ctx.props.autoHazard !== false && once(ctx, "hazard-zone", false)) {
ctx.events.emit("send-pedestrian", { pedestrian: ctx.props.pedestrian ?? "pedestrian-1" })
}
return
case "crossing":
if (!once(ctx, "crossing")) return
report(ctx, "crossing-entered", { subject: ctx.entity.id })
ctx.hud.objective("objective.destination")
return
case "destination":
if (!once(ctx, "destination")) return
report(ctx, "destination-reached", { subject: ctx.entity.id, seconds: round1(s.seconds) })
ctx.hud.objective("objective.arrived")
// A HUD text is a key into meta.strings, never the text itself: one key per state, numbers as numbers.
ctx.hud.set("stop", s.stopped ? "result.stopped" : "result.not-stopped")
ctx.hud.set("over", s.overCount, { label: "Times over the limit" })
ctx.hud.set("collisions", s.collisions, { label: "Collisions" })
return
case "pedestrian-contact":
collided(ctx, t.volume.id)
return
}
},
onCollision(ctx, c) {
// Only what is tagged as an obstacle counts. The car touches the floor and the road's surface (an entity the
// road builds, `route-surface`) all the time, and that is driving, not a collision.
if (OBSTACLES.some((tag) => c.other.tags.includes(tag))) collided(ctx, c.other.id)
},
} satisfies Behavior<State, Props>
Things to notice:
- Nothing reads the wall clock. Time is the sum of
dt. That is why a run plays back the same. - Each place reports once (
once), and the three places on the route also emitgoal-reached, which the self-test counts. - The stop is judged over a window: the slowest speed while the car's front is within 10 m of the line.
- Only tagged obstacles are collisions. The car touches the floor and the road's surface all the time, which is driving.
- Every event is also logged (
report), so a run in a terminal prints it. - The HUD takes keys, not text.
ctx.hud.objective("objective.stop")shows the stringobjective.stopfrommeta.strings, andctx.hud.set("speed", 42, { label: "Speed", unit: "km/h" })shows a number with its words. A text you build at run time would show as[[the text]]. The critic warns about it (missing-string).
11. The pedestrian: pedestrian.ts
import type { Behavior, Ctx, Vec3 } from "@argus/api"
/**
* Crossing Drive: a pedestrian who steps out across the road when told to.
*
* It waits on the pavement until the bus event `send-pedestrian` arrives. The trainee's car sends it on entering
* the hazard zone; an instructor or a test can send it at any time with `{ pedestrian: "<this entity's id>" }`, or
* with no data for every pedestrian. Then it walks to `to` at `speed` m/s and emits
* `pedestrian-stepped-out` `{ pedestrian, distance_m }`, the distance being from the car it `watch`es.
* The event is also written to the log, so a headless run prints it. It goes once, and stops where it is if the car
* touches it (the car reports that as `trainee-collision`).
*
* As it walks, its limbs swing: children whose ids end in `-leg-l`, `-leg-r`, `-arm-l` and `-arm-r` turn about
* the hip and the shoulder, a stride every `STRIDE_M` metres. A pedestrian without them glides.
*/
/** Metres walked per stride, left foot to left foot. */
const STRIDE_M = 1.5
/** How far the legs and the arms swing each way, radians. */
const LEG_SWING = 0.42
const ARM_SWING = 0.32
/** A limb turns about this share of its half-length above its centre: near the top of the capsule. */
const PIVOT = 0.8
/** Seconds the limbs take to come to rest when it stops. */
const SETTLE_S = 0.3
/** Each limb: its id's ending, its swing, and which way it swings (an arm against the leg on its side). */
const LIMBS: [string, number, number][] = [
["-leg-l", LEG_SWING, 1],
["-leg-r", LEG_SWING, -1],
["-arm-l", ARM_SWING, -1],
["-arm-r", ARM_SWING, 1],
]
interface State {
walking: boolean
gone: boolean
/** Metres walked: the stride's phase. */
walked: number
/** How much of the swing shows: 1 while walking, easing to 0 when it stops. */
swing: number
}
interface Props {
/** Where it walks to: the far pavement. */
to?: Vec3
/** Walking speed, m/s. */
speed?: number
/** The car the reported distance is measured from. */
watch?: string
}
type C = Ctx<State, Props>
/** Turns each limb about its top (about X; positive swings it forward, toward -Z), keeping the top where it is. */
function pose(ctx: C, phase: number, amount: number) {
for (const limb of ctx.entity.children) {
const kind = LIMBS.find(([end]) => limb.id.endsWith(end))
if (kind === undefined) continue
const angle = kind[1] * kind[2] * Math.sin(phase) * amount
const was = 2 * Math.atan2(limb.rotation[0], limb.rotation[3])
const d = PIVOT * limb.scale[1]
const [x, y, z] = limb.position
const topY = y + d * Math.cos(was)
const topZ = z + d * Math.sin(was)
limb.position = [x, topY - d * Math.cos(angle), topZ - d * Math.sin(angle)]
limb.rotation = [Math.sin(angle / 2), 0, 0, Math.cos(angle / 2)]
}
}
export default {
init(ctx) {
if (typeof ctx.state.walking !== "boolean") {
ctx.state.walking = false
ctx.state.gone = false
}
if (typeof ctx.state.walked !== "number") {
ctx.state.walked = 0
ctx.state.swing = 0
}
},
update(ctx, dt) {
const s = ctx.state
if (s.walking) {
const to = ctx.props.to ?? [-6, 0, 0]
const step = (ctx.props.speed ?? 1.4) * dt
if (ctx.entity.moveToward(to, step)) s.walking = false
s.walked += step
s.swing = 1
} else if (s.swing > 0) {
s.swing = Math.max(0, s.swing - dt / SETTLE_S)
} else {
return
}
pose(ctx, (s.walked / STRIDE_M) * 2 * Math.PI, s.swing)
},
onEvent(ctx, ev) {
if (ev.name !== "send-pedestrian" || ctx.state.gone) return
// From a script: `{ pedestrian }`. Injected by an instructor: the session command's `target`, if it names one.
const data = ev.data as { pedestrian?: string; target?: string | null } | null
const who = data?.pedestrian ?? (typeof data?.target === "string" && data.target !== "world" ? data.target : undefined)
if (who !== undefined && who !== ctx.entity.id) return
ctx.state.gone = true
ctx.state.walking = true
const car = ctx.world.find(ctx.props.watch ?? "car")
const distance = car === null ? -1 : Math.round(car.distanceTo(ctx.entity) * 10) / 10
const stepped = { pedestrian: ctx.entity.id, distance_m: distance }
ctx.events.emit("pedestrian-stepped-out", stepped)
ctx.log("pedestrian-stepped-out", stepped)
},
onTrigger(ctx, t) {
// Touched by the trainee's car: stop where it is.
if (t.entering && t.event === "pedestrian-contact" && t.other.tags.includes("trainee")) ctx.state.walking = false
},
} satisfies Behavior<State, Props>
The car sends send-pedestrian when it enters the hazard zone. On day 2 the instructor's console sends the same event, so the hazard can come whenever the instructor chooses. Set the car's autoHazard prop to false and only the instructor sends it.
12. The events
These are the scenario's output. Day 2 declares them, scores them and marks them on the review timeline.
| Event | Data | When |
|---|---|---|
speed-over | subject, speed_kmh, limit_kmh | the speed goes over the limit (once until it is back under) |
brake-applied | subject, speed_kmh | the brake goes on (once per press) |
stop-line | subject, stopped, min_kmh, speed_kmh | the car's front reaches the line; min_kmh is the slowest it went in the last 10 m, stopped is true if that was under 1 km/h, and speed_kmh is its speed crossing the line |
pedestrian-stepped-out | pedestrian, distance_m | the pedestrian starts across; the distance is from the car |
crossing-entered | subject | the car reaches the crossing |
trainee-collision | subject, other, speed_kmh | the car hits an entity tagged building, pedestrian or traffic |
destination-reached | subject, seconds | the car reaches the green post |
subject is the trainee's car. A field named after a role (subject, other, pedestrian) is an entity id: on day 2 the journal links the event to that entity.
To see them: every event is also logged, so step 14's self-test prints the run line by line, each line starting with the entity's id:
[car] stop-line {"min_kmh":0,"speed_kmh":19.2,"stopped":true,"subject":"car"}
[pedestrian-1] pedestrian-stepped-out {"distance_m":36.4,"pedestrian":"pedestrian-1"}
13. Change it
| To | Change |
|---|---|
| Lower the limit | the car's limitKmh prop |
| Make the stop stricter | STOPPED_KMH or STOP_WINDOW_M at the top of drive-rules.ts |
| Bring the pedestrian out later | move hazard-zone toward the crossing |
| Change the route | the points in roads/route.road.json (move the stop line, crossing and destination with it, then re-tune the self-test) |
| Drive something else | the car's vehicle preset: suv, pickup or van (Vehicles) |
| Count more things as collisions | give them the tag building, pedestrian or traffic, or add a tag to OBSTACLES in drive-rules.ts |
Ask: "Move the stop line 20 m further on, and make the pedestrian walk at 2 m/s." Your assistant moves the paint, the sign and the volume together, and changes the pedestrian's
speedprop.
14. A self-test
A self-test is a script of inputs the engine plays by itself, headless, in fixed ticks (Self-test). This one drives like a careful trainee:
- under the limit;
- a full stop just short of the stop line;
- a brake as the pedestrian steps out, and a wait while they cross;
- then on to the green post.
Each drive step follows the road at a throttle for a number of ticks, each axis step holds the throttle (a negative value brakes), and each action step holds a button, brake or handbrake. End a brake just before the car stops and hold the handbrake while it waits: a brake or a negative throttle held at a standstill reverses. The numbers are tuned to this route: change the route or move the volumes, and tune them again.
What it proves: the route can be driven end to end, every rule fires (the lines it prints), and no script fails. It passes when the stop line, the crossing and the green post are reached with no script error. It does not judge the drive: a run that misses the stop still passes. Judging is day 2's scorecard. Every export carries the self-test, so it also checks a training PC.
Ask: "Write a self-test
crossing-drivethat drives the route carefully, stops at the line, brakes for the pedestrian, and expects the three goals. Run it, and tune it until the stop line says stopped." (Your assistant writes it withbehaviour.writeand runs it withcritic.selftest.)
By hand: create selftest/crossing-drive.json in the project folder (beside scenes/):
{
"selfTest": 1,
"description": "A careful drive: under the limit, a full stop short of the stop line, a brake as the pedestrian steps out, a wait while they cross, then on to the green post. Passes when the stop line, the crossing and the green post are reached with no script errors. Its timings are this route's: move the route or the volumes and re-tune them.",
"maxFrames": 4200,
"steps": [
{
"frames": 30
},
{
"drive": "road",
"road": "route",
"throttle": 0.25,
"lookAheadM": 12,
"frames": 600
},
{
"drive": "road",
"road": "route",
"throttle": 0.0,
"lookAheadM": 12,
"frames": 105
},
{
"action": "brake",
"frames": 82
},
{
"action": "handbrake",
"frames": 128
},
{
"drive": "road",
"road": "route",
"throttle": 0.25,
"lookAheadM": 12,
"frames": 360
},
{
"drive": "road",
"road": "route",
"throttle": 0.0,
"lookAheadM": 12,
"frames": 115
},
{
"axis": "throttle",
"value": [
-0.3,
0
],
"frames": 183
},
{
"action": "handbrake",
"frames": 297
},
{
"drive": "road",
"road": "route",
"throttle": 0.25,
"lookAheadM": 12,
"frames": 500
},
{
"axis": "throttle",
"value": [
-0.4,
0
],
"frames": 30
},
{
"drive": "road",
"road": "route",
"throttle": 0.15,
"lookAheadM": 12,
"frames": 220
},
{
"axis": "throttle",
"value": [
-0.4,
0
],
"frames": 30
},
{
"drive": "road",
"road": "route",
"throttle": 0.15,
"lookAheadM": 12,
"frames": 220
},
{
"axis": "throttle",
"value": [
-0.4,
0
],
"frames": 30
},
{
"drive": "road",
"road": "route",
"throttle": 0.15,
"lookAheadM": 12,
"frames": 220
},
{
"axis": "throttle",
"value": [
-0.4,
0
],
"frames": 30
},
{
"drive": "road",
"road": "route",
"throttle": 0.15,
"lookAheadM": 12,
"frames": 160
},
{
"action": "brake",
"frames": 80
},
{
"action": "handbrake",
"frames": 70
},
{
"expect": "goals",
"min": 3,
"timeout": 10
},
{
"capture": "arrived"
}
],
"expect": {
"scriptErrors": 0
}
}
and run it from a terminal in the project folder:
argus run . --headless --self-test crossing-drive
You should see the run, then the verdict:
[car] brake-applied {"speed_kmh":47.2,"subject":"car"}
[car] stop-line {"min_kmh":0,"speed_kmh":19.2,"stopped":true,"subject":"car"}
argus: self-test: goal reached: "stop-line" at frame 1257
[pedestrian-1] pedestrian-stepped-out {"distance_m":36.4,"pedestrian":"pedestrian-1"}
[car] brake-applied {"speed_kmh":39.9,"subject":"car"}
[car] crossing-entered {"subject":"car"}
argus: self-test: goal reached: "crossing" at frame 2454
...
[car] destination-reached {"seconds":58.7,"subject":"car"}
argus: self-test: goal reached: "destination" at frame 3552
argus: self-test passed: ...self-test.json
The report, self-test.json, goes to the engine's log folder (%LOCALAPPDATA%\Argus\logs on Windows, ~/.local/state/argus/logs on Linux). Set ARGUS_SELF_TEST_OUT to a file path to put it elsewhere. A failed run names the step that failed and the goals it reached. Each drive step's distanceM and s in the report say how far it got, which is what you tune.
Done
You have a scenario a trainee can drive, with its rules in two short scripts, its events listed, and a self-test. Compare it with the sample: your scenes/main.scene.json should match it entity for entity, in the same order. Then go on to day 2: Events, metrics and scorecards.