Repository navigation
Scenarios
A scenario is a UTF-8 JSON file describing the starting body position, food, circular obstacles, hazards, arm parameters, reach targets and signalling/conflict policies. The Python application and web explorer accept the same version-1 format; older files without the new optional fields remain valid. No additional dependencies are needed.
You can supply a scenario when launching the desktop application, or load one after it has started. Both routes validate the file, replace the scene with its starting conditions and leave playback paused.
From the project directory, load the included example:
./scripts/run.sh scenarios/two-targets.jsonWith the virtual environment activated, either entry point also accepts a file:
octopus-controller scenarios/two-targets.json
python -m octopus_controller scenarios/two-targets.jsonPaths are relative to the directory from which you launch the application, unless you supply an absolute path. Quote a path containing spaces.
Start without a filename:
./scripts/run.shWith the virtual environment activated, you can instead run octopus-controller or python -m octopus_controller. The desktop opens an empty, paused scene with eight idle arms.
- Click Load in the Scenario card, or press Ctrl+O (Command+O on macOS)
- Select a
.jsonfile in the file picker, then click Open or press Enter. To browse into a folder, select it and use Open or Enter first. The picker initially uses the project'sscenariosdirectory when available - Alternatively, click the path field and enter a file or directory path. Relative paths are resolved from the folder currently shown in the picker
- After loading, check the scenario name in the card and window title. The body is centred and the arms fitted into view; playback remains paused
- Press Space to begin
The same Load control lets you switch scenarios without closing the application. Loading replaces the current scene, so use Save first if you want to retain an edited setup. Cancel or Esc closes the picker without loading. A file that fails validation displays an error and leaves the current scene intact.
| Control | Action |
|---|---|
| Space | Play or pause. |
| N | Advance one simulation tick while paused. |
| V | Cycle playback through 1×, 2×, 4× and 8×. |
| D | Restart the loaded setup and leave it paused. |
| R | Reset only the selected arm's pose. |
Selection, target dragging, object editing and parameter controls remain available. Faster playback runs more unchanged simulation ticks; single-step still advances exactly one tick at any speed.
Restart restores the initial objects (including hazards), targets, policies, parameters and arm poses. It clears elapsed time, grips, recoil episodes, reports, conflict reservations and histories. It uses the validated setup held in memory: to pick up external JSON edits, load the file again, rather than merely restarting it.
Choose a bundled scenario from Starting conditions, or use Load a scenario file to select a custom JSON file. Loaded scenarios start paused. Use Play, Step, Restart and Playback speed beneath the scenario controls. Reload a custom file to pick up external edits; Restart restores the already-loaded starting conditions.
{
"version": 1,
"name": "Two arms reaching for food",
"body": [400, 460],
"defaults": {
"segment_count": 20,
"segment_length": 12,
"maximum_bend_degrees": 60,
"turning_speed_degrees": 90
},
"food": [
{ "id": "meal-a", "position": [400, 240], "radius": 12 },
{ "id": "meal-b", "position": [620, 460], "radius": 12 }
],
"obstacles": [
{ "position": [520, 570], "radius": 25 }
],
"arms": [
{ "number": 1, "target": { "food": "meal-a" } },
{
"number": 3,
"parameters": { "segment_count": 24 },
"target": { "food": "meal-b" }
},
{ "number": 5, "target": { "position": [400, 650] } }
]
}The file must contain one JSON object, encoded as UTF-8. Field names are case-sensitive and must match the names below exactly. Unknown fields and duplicate JSON keys are rejected at every level. Comments and trailing commas are not supported.
Numbers must be JSON numbers, not quoted strings or booleans. All coordinates, lengths, radii and speeds must be finite; NaN, infinity and numbers too large to represent as finite floating-point values are rejected. Integer fields require integer notation: use 20, not 20.0.
Optional fields should be omitted to select their defaults. Explicit null is not a substitute for omission. Empty objects are allowed for defaults, parameters, escalation and conflicts; empty arrays are allowed for food, obstacles, hazards and arms. Policy objects are partial overrides: omitted members keep their defaults, and the resulting complete policy must be valid.
| Field | JSON type | Required | Accepted values / meaning | Default when omitted |
|---|---|---|---|---|
version |
Integer | Yes | Exactly 1
|
None |
name |
String | No | Contains at least one non-whitespace character; displayed in the window title | "Untitled scenario" |
body |
Array of two numbers | No | Fixed body centre [x, y]; both coordinates finite |
[400, 460] |
defaults |
Object | No | Any of the four arm parameters listed below | Built-in arm defaults |
food |
Array of objects | No | Food circles, as described below | [] |
obstacles |
Array of objects | No | Obstacle circles, as described below | [] |
hazards |
Array of objects | No | Traversable hazard circles with severity and sensing range | [] |
escalation |
Object | No | Shared reporting, withdrawal, exposure and clearance thresholds | Built-in escalation policy |
conflicts |
Object | No | Critical broadcast threshold and coordinate claim tolerance | Built-in conflict policy |
arms |
Array of objects | No | Up to eight entries with distinct arm numbers |
[]; all eight arms idle |
The smallest valid scenario is:
{ "version": 1 }This creates eight idle arms at the default body position with no food, obstacles or hazards.
Every body or position value is an array of exactly two numbers: [x, y]. Coordinates, segment lengths and object radii are measured in world pixels. Camera fitting can scale and translate their on-screen appearance without changing saved values. X increases rightwards and Y increases downwards. Fractional and negative coordinates are accepted.
Coordinates are not restricted to the viewport. The desktop camera centres the body and fits the arms’ full reach; it does not fit every distant object or target. Keep the intended action near the body and within the arms’ reach for a readable demonstration. Moving the camera does not change sensing distances or collision rules.
Arms retain the standard radial attachment points around the body and gently curved initial poses. The format does not set individual attachment points or joint angles.
Each object in food accepts only these fields:
| Field | JSON type | Required | Accepted values / range | Default when omitted |
|---|---|---|---|---|
position |
Array of two numbers | Yes |
[x, y], both finite |
None |
radius |
Number | No | Finite and strictly greater than 0 pixels; no explicit upper bound | 14 |
id |
String | No | Contains at least one non-whitespace character; unique among food IDs | No named reference |
Food without an id can still be contacted and captured. IDs are case-sensitive and compared exactly; whitespace is not trimmed. For example, "meal-a" and "Meal-A" are different IDs.
{
"version": 1,
"food": [
{ "id": "meal-a", "position": [400, 240], "radius": 12 },
{ "position": [300, 350] }
]
}Each object in obstacles accepts only these fields:
| Field | JSON type | Required | Accepted values / range | Default when omitted |
|---|---|---|---|---|
position |
Array of two numbers | Yes |
[x, y], both finite |
None |
radius |
Number | No | Finite and strictly greater than 0 pixels; no explicit upper bound | 14 |
Obstacles are circles. They do not accept an id, shape, width or height. Their collection determines their type, so neither food nor obstacles accept a kind field.
Add a top-level hazards array. Each entry accepts only:
| Field | JSON type | Required | Accepted values | Default when omitted |
|---|---|---|---|---|
position |
Array of two numbers | Yes | Finite [x, y]
|
None |
radius |
Number | No | Finite and strictly positive | 14 |
severity |
Number | No | Finite, greater than 0 and at most 1 | 0.5 |
detection_range |
Number | No | Finite and non-negative, in world pixels | 24 |
Hazards do not accept id, kind, a name or a shape. Their numeric runtime identities are assigned by the loader. They cannot be referenced using a food target. Food and obstacles do not accept hazard-only fields such as severity.
A hazard is traversable and produces avoidance; an obstacle is a solid collision constraint. Detection tests the full segment against radius + detection_range + 3 (the 3-pixel sensing strip). A zero detection range is valid and still includes that strip. Exposure means strict centreline penetration of the actual radius, not merely detection.
This complete file places a mild hazard near the initial right-hand arm:
{
"version": 1,
"name": "Mild hazard beside Arm 3",
"hazards": [
{ "position": [550, 480], "radius": 10, "severity": 0.25, "detection_range": 12 }
],
"arms": [
{ "number": 3, "target": { "position": [620, 460] } }
]
}Recoil has no scenario field of its own. New encounters at severity 0.4 or above trigger local recoil for 0.25 + 0.9 × severity simulation seconds. Lower severities use local avoidance, including outward steering if already penetrating. Changing the reporting policy below does not change this local recoil threshold or duration.
The optional top-level escalation object applies to every arm:
| Field | JSON type | Default | Constraint and meaning |
|---|---|---|---|
report_severity |
Number | 0.4 |
Minimum severity for a central report |
withdrawal_severity |
Number | 0.75 |
Minimum severity for an immediate withdrawal request |
exposure_seconds |
Number | 2.0 |
Strictly positive continuous exposure before a reportable hazard escalates |
clear_seconds |
Number | 0.6 |
Strictly positive time outside sensing range before a report clears |
All values must be finite, with 0 < report_severity < withdrawal_severity <= 1. Exposure and clearance use simulation seconds, so playback speed changes wall-clock duration rather than these thresholds.
Below the report threshold, danger stays local even after prolonged exposure. At or above the report threshold, the arm sends an informational report. It requests withdrawal when severity reaches the withdrawal threshold or continuous exposure reaches exposure_seconds. Exposure resets when penetration stops. An outstanding report clears only after continuous absence from sensing range for clear_seconds; lowering severity alone does not clear it.
For a quicker sustained-exposure demonstration, override just the exposure interval:
{
"version": 1,
"escalation": { "exposure_seconds": 0.75 },
"hazards": [
{ "position": [550, 480], "radius": 14, "severity": 0.55, "detection_range": 14 }
],
"arms": [
{ "number": 3, "target": { "position": [620, 460] } }
]
}This configures earlier escalation if penetration remains continuous; the layout does not force the arm to stay exposed. Use hazard-escalation.json or reef-forage-extended.json as starting points for an established demonstration.
The optional top-level conflicts object accepts:
| Field | JSON type | Default | Constraint and meaning |
|---|---|---|---|
critical_severity |
Number | 0.95 |
Finite, greater than 0 and at most 1; critical threshold for all-arm withdrawal |
target_tolerance |
Number | 2.0 |
Finite and non-negative; world-pixel distance for matching coordinate claims |
All-arm withdrawal requires an outstanding priority-100 report at or above critical_severity. Lowering this setting does not cause unreported hazards or informational reports to trigger a broadcast. For immediate severity-based broadcasts in a custom policy, ensure the hazard also meets withdrawal_severity.
The priorities themselves are fixed: withdrawal 100, local avoidance 80, grip 60, assigned reach 50, discovery 20 and idle 0. There are no JSON fields for custom priority weights, named conflict scripts, forced winners or fairness timeouts.
To introduce competition, assign the same food to more than one arm:
{
"version": 1,
"name": "Shared food with a critical hazard",
"food": [
{ "id": "shared-meal", "position": [200, 460] }
],
"hazards": [
{ "position": [550, 480], "radius": 8, "severity": 1.0, "detection_range": 8 }
],
"escalation": {
"report_severity": 0.4,
"withdrawal_severity": 0.75,
"exposure_seconds": 2.0,
"clear_seconds": 0.6
},
"conflicts": { "critical_severity": 0.95, "target_tolerance": 2 },
"arms": [
{ "number": 7, "target": { "food": "shared-meal" } },
{ "number": 8, "target": { "food": "shared-meal" } }
]
}When eligible, the existing owner wins, then the eligible incumbent, then the lowest arm number. Other claimants yield while retaining their assignments. Safety can override that contest, including withdrawal of idle arms when critical danger is reported. Remove the hazard from this example to isolate shared-target arbitration.
When both claims identify food, identity determines whether they compete. Otherwise, destinations within target_tolerance can compete, including a coordinate target beside a named-food target. A tolerance of 0 requires identical coordinates for that comparison. Matching uses the first target in a group as its anchor; it does not merge a chain of increasingly distant targets.
Each object in arms accepts only these fields:
| Field | JSON type | Required | Accepted values / meaning | Default when omitted |
|---|---|---|---|---|
number |
Integer | Yes | 1–8 inclusive; each number may appear once | None |
parameters |
Object | No | Any of the four arm parameters listed below | Inherit shared defaults |
focus_food |
String | No | Existing food ID; requires a target and must agree with any named-food target | No extra food focus |
target |
Object | No | Exactly one position or food field |
Arm remains idle |
Numbering starts at the top and proceeds clockwise, matching the keyboard controls:
| Arm | Attachment direction |
|---|---|
| 1 | Up |
| 2 | Upper right |
| 3 | Right |
| 4 | Lower right |
| 5 | Down |
| 6 | Lower left |
| 7 | Left |
| 8 | Upper left |
The order of entries does not matter. Unlisted arms still exist and receive the shared defaults, but remain idle. A listed arm without a target also remains idle; this allows its parameters to be configured without starting a reach.
These same names are accepted in both top-level defaults and each arm's parameters object. All are optional.
| Parameter | JSON type | Units | Accepted range | Built-in default |
|---|---|---|---|---|
segment_count |
Integer | Segments | 2–100 inclusive | 20 |
segment_length |
Number | Pixels per segment | Finite and strictly greater than 0; no explicit upper bound | 12 |
maximum_bend_degrees |
Number | Degrees | Strictly greater than 0 and at most 180 | 60 |
turning_speed_degrees |
Number | Degrees per second per joint | Finite and strictly greater than 0; no explicit upper bound | Approximately 85.9436692696 (1.5 radians/second) |
maximum_bend_degrees limits a joint's relative bend in either direction. turning_speed_degrees limits how quickly each joint turns; it is not a tip speed in pixels per second. Zero speed is invalid: omit the arm's target to leave it idle, or pause the simulation.
Degree values are converted internally to radians. Use the degree-suffixed names above in files; maximum_bend and turning_speed are not accepted file fields. Values must remain positive and finite after conversion to the model's floating-point units; extremely tiny degree values that underflow to zero are rejected.
Parameter precedence, for each individual field, is:
- The built-in default.
- A replacement in top-level
defaults, if supplied. - A replacement in the arm's own
parameters, if supplied.
For example, the complete example above gives every arm 20 segments except Arm 3, which has 24. All arms inherit a segment length of 12, a bend limit of 60 degrees and a turning speed of 90 degrees/second. Overriding one parameter does not discard the others.
The file ranges are wider than some interactive keyboard controls. Keyboard edits retain their existing limits: 2–40 segments, lengths of 6–30 pixels, bends of 5–180 degrees, and turning speeds of 0.25–4 radians/second (approximately 14.32–229.18 degrees/second). Editing a file-supplied value outside a keyboard range can clamp it back into that range.
If target is present, it must contain exactly one of the following fields:
| Field | JSON type | Accepted values | Behaviour |
|---|---|---|---|
position |
Array of two numbers |
[x, y], both finite |
Assign a fixed tip destination |
food |
String | Exact ID of an existing entry in food
|
Assign its initial centre and retain its identity for capture filtering and arbitration |
Coordinate target:
{
"version": 1,
"arms": [
{ "number": 1, "target": { "position": [400, 240] } }
]
}Named food target:
{
"version": 1,
"food": [
{ "id": "meal-a", "position": [400, 240] }
],
"arms": [
{ "number": 1, "target": { "food": "meal-a" } }
]
}A food reference resolves its destination at load time and retains food identity. It does not track food moved later or grant immediate ownership. It participates in resource arbitration, and the arm ignores incidental food that conflicts with that explicit assignment. Contact and grasp dwell are still needed for capture. Several arms may reference the same food; eligible losers yield. A coordinate-only target without focus_food remains opportunistic and may capture food encountered along the way.
An empty target, null, an unknown food ID, or a target containing both food and position is invalid. Omit target entirely for an idle arm.
Use arm-level focus_food when the desired coordinate should remain fixed independently of the food's current location, while preserving the identity used for conflict resolution and capture filtering:
{
"version": 1,
"food": [
{ "id": "meal-a", "position": [420, 250] }
],
"arms": [
{
"number": 1,
"target": { "position": [400, 240] },
"focus_food": "meal-a"
}
]
}focus_food belongs beside target, not inside it. It must be a string naming existing food and cannot appear without a target. If used with target.food, the two IDs must match; normally target.food alone is sufficient. The destination need not equal the focused food's centre, but an unreachable or misplaced destination may prevent capture.
Saving may generate this form to preserve a retained destination after food has moved. It does not preload a grip or turn the target into automatic object tracking.
Saving includes hazard positions, radii, severities and detection ranges, plus both policy objects. Named-food focus is preserved using exported food IDs and focus_food where needed. Exported food names can differ from the original file's labels.
Saved files describe starting conditions, not a continuation checkpoint. They do not preserve hazard exposure, recoil timers, reports, reservations, joint poses, grips, capture history or conflict history. Currently held food is exported as unheld food at its current position. A carrying destination can be exported, but loading it creates an ordinary unladen assignment rather than a preloaded carry.
To demonstrate hold-versus-withdrawal, arrange for the arm to capture food before detecting serious danger, or interactively introduce a hazard after capture. food-vs-danger.json and reef-forage-extended.json provide examples. A critical hazard detected immediately can interrupt reaching before any grip exists.
The entire file is checked before a window opens. Invalid input reports the filename and a useful error, for example Arm 3 references unknown food 'meal-c'.
Loaded targets use the existing distributed movement, obstacle avoidance and contact-based grasping rules. The loader does not promise that targets are reachable or that an obstacle layout admits a route. Multiple arms can target the same food; conflict arbitration selects eligible claimants before movement, and capture reservations prevent double ownership. This format stores starting conditions, not an in-progress simulation or a sequence of future commands.
| Invalid input | Reason / correction |
|---|---|
"version": 2 |
Only version 1 is supported |
"segment_count": 20.0 |
Use the integer 20
|
"segment_length": "12" |
Use a number, not a string |
"maximum_bend_degrees": 181 |
Maximum is 180 degrees |
"turning_speed_degrees": 0 |
Speed must be strictly positive |
"position": [400] |
Supply exactly two coordinates |
"radius": -5 |
Radius must be strictly positive |
Two entries with "number": 3
|
Each arm number may appear only once |
Two food entries with the same id
|
Food IDs must be unique |
"target": {} |
Supply one target field, or omit target
|
"target": { "food": "missing" } |
Define food with that exact ID |
Hazard "severity": 0 or 1.1
|
Use a finite value greater than 0 and at most 1 |
Hazard "detection_range": -1
|
Use a finite non-negative value |
Hazard "id": "danger"
|
Hazard IDs are assigned internally; omit this field |
"escalation": null |
Omit the object for defaults, or use {}
|
report_severity equal to or above withdrawal_severity
|
The report threshold must be strictly lower after defaults are applied |
"exposure_seconds": 0 or "clear_seconds": 0
|
Both intervals must be strictly positive |
"critical_severity": 0 |
Use a finite value greater than 0 and at most 1 |
"target_tolerance": -1 |
Use a finite non-negative distance |
focus_food without a target or with an unknown ID |
Supply a target and an existing food ID |
Different IDs in focus_food and target.food
|
They must identify the same food |
"recoil_seconds": 1 |
No such scenario field; recoil timing is a local model rule |
"segment_lenght": 12 |
Unknown field; use segment_length
|
The loader validates the file structure and parameter values, not the physical feasibility of the experiment. Overlapping obstacles, obstacles intersecting initial arm poses, or targets inside obstacles may be accepted as a file but prevent movement. The format has no fields for autoplay, custom arm counts, object tracking, command sequences or saved simulation progress.