Skip to content

Authoring a new world

End-to-end guide for creating a world that Arena can load. Covers the manual path; see scripts/generate_world for the AI-assisted generation pipeline.

Prerequisites

  • Arena workspace built (colcon build).
  • arena_simulation_setup on $AMENT_PREFIX_PATH.
  • touch_world script available: ros2 run arena_simulation_setup touch_world.

1. Create the world directory

export WORLD=my_world
mkdir -p \
    $ARENA_DIR/arena_simulation_setup/worlds/$WORLD/scenarios/default \
    $ARENA_DIR/arena_simulation_setup/worlds/$WORLD/assets

$ARENA_DIR is the workspace source root. If unset, touch_world uses the current working directory.

WorldIdentifier resolves a name against, in order: ARENA_WORLD_PATH roots, then the writable local root $ARENA_ASSETS_DIR_LOCAL/worlds, then this package's worlds/, then a network bucket named by WORLD_BUCKETS. Worlds saved through resolve_write_sync() (the world generator, the worlds MCP tools) land in the writable local root, outside the repo and the install space. Once authored, publish a world with arena asset push world <name>, which refuses to publish if any asset it references would not resolve for someone else. arena asset find world <name> shows which source a name currently resolves against. See tree/README.md.

2. Author world.yaml

A world is one file, worlds/$WORLD/world.yaml, holding every level. The minimum valid file is one level with one walled zone:

format: arena-world/2
levels:
- id: '0'
  zones:
  - name: main_room
    polygon: [[0, 0], [10, 0], [10, 10], [0, 10]]
    walls: outline

The full shape, every key optional except format, levels, a level's id and a zone's name:

format: arena-world/2
defaults:                                # inherited by every level and item that does not set its own
  floor: Concrete_Smooth                 # material, or false for no floor
  ceiling: {height: 2.7, material: Concrete_Smooth}   # or false for open zones
  wall: {material: Plaster_Wall}         # or {kind: patient_room_walls}
  door: {kind: sliding, material: Aluminum_Anodized}
schedules: [...]                         # world scope, see M2 kinds below
signals: [...]
levels:
- id: '0'
  pedestrian_walls: true                 # false drops the occupancy-detected pedestrian walls
  zones:
  - name: central_hallway
    polygon: [[8, 0], [12, 0], [12, 34.2], [8, 34.2]]
    floor: Marble
    ceiling: {height: 3.0, cast_shadows: false, lights: {fixture: panel, spacing: 2.4, lumens: 3600}}
    walls: outline                       # or {outline: true, open_to: [lobby], material: ..., kind: ...}
    semantics: [{state: max_speed, value: 1.5}]
  walls:
  - {path: [[0, 10], [0, 20], [8, 20]], kind: patient_room_walls}
  - {path: [[20, 0], [24, 0], [24, 4], [20, 4]], closed: true}
  doors:
  - {name: main_entrance, span: [[9.25, 0], [10.75, 0]]}
  elevators:
  - {name: lift_0, position: [5, 0], destinations: [lift_1]}
  objects:
  - {name: desk_1, model: Common/Desk, pose: [2.0, 1.0, 1.5708]}
  humans: []
  lights:
  - {name: exit_strip, fixture: tube, lumens: 300, pose: {x: 10, y: 34, z: 2.2}}
  sounds:
  - {name: hall_siren, asset_id: alarm_loop, position: [4.0, 2.3]}
  microphones:
  - {zone: central_hallway, placement: ceiling, position: [10, 5, 3.0]}

Key points:

  • A zone is a named region: its polygon (an open ring, the interior is navigable floor), its floor and ceiling, its runtime semantics, and optionally walls along its outline. It holds nothing else. Walls, doors, elevators, objects, humans, lights, sounds and microphones are lists of the level, and which zone a thing belongs to is a semantic fact (see KG).
  • Every name (zone, door, elevator, object, human, light, sound, schedule, signal) is unique across the whole world, so a reference never needs a level. Loading fails on a repeated name, on a key the format does not know (the error names the YAML path, e.g. levels[0].doors[3]: unknown keys ['heigth']) and on a reference to an unknown name.
  • Positions are [x, y], [x, y, z] or {x, y[, z]}. Poses take the forms of Pose.parse in utils/geometry.py: [x, y], [x, y, yaw] (radians), {x, y[, z][, roll][, pitch][, yaw]}, [x, y, z, roll, pitch, yaw], [x, y, z, w, x, y, z] or a zone name.
  • A material is Name (domain Common), Domain/Name or [Name, {modifiers}].
  • defaults apply to every zone, wall, outline and door that does not set the key itself. Without defaults, a zone has a Concrete_Smooth floor and ceiling, and walls and doors take the package defaults.
  • walls: entries are polylines, closed: true joins the last point back to the first, kind: <name> references a WallIdentifier asset and material: an inline material, never both. walls: outline on a zone draws a wall along every polygon edge, open_to: leaves open the edges it shares with the named zones. Repeated walls are merged, and a door cuts every wall it lies on, so draw walls straight through doorways.
  • objects: and humans: take the same schema as a scenario's lists (see 4. Add a scenario) and exist in every scenario run against the world.

Linked levels

A level can come from another world instead of being written out: - {from: <world>[:<level id>], id: <level id>}. <world> is a world name, resolved like any world, or a path relative to this world's directory (./floors/2), so a tall building keeps each floor in its own small format 2 world under a subfolder:

format: arena-world/2
levels:
- from: ./floors/ground            # floors/ground/world.yaml, a one-level world
- from: ./floors/first
- {id: '2', from: 'office_1:0'}     # level 0 of the office_1 world, as level 2 here

id defaults to the level's id in its source, and the level id may be left out when the source has a single level. A linked level is taken as its source defines it, defaults included. Its only other key is prefix:, put before every name of the level and every reference to one, so the same floor can repeat (- {id: '3', from: ./floors/typical, prefix: f3_}) and two worlds sharing a zone name can sit side by side. Names stay unique across the composed world, elevator destinations that point outside it are dropped with a warning, and a link back into a world already being read is an error. Assets local to a linked world (<world>/assets/) resolve after this world's own. Saving the world writes the links, not the levels they name. arena asset push world refuses a world whose ./ or ../ link leaves its folder, or that links a world only this machine has.

A linked level cannot set the destinations of its elevators, so the destinations that leave the composed world are dropped and the linked levels whose elevators are then left without one ride between each other: every linked level with exactly one such cabin joins a ring in level order (two levels ride to each other, three as 0 to 1, 1 to 2, 2 to 0). A level with several such cabins stays out of the ring with a warning naming them.

Any world name also takes a comma list, world:=hospital_1,office_1, which stacks the listed worlds as the levels of one world without writing a file: each level of each world links in order with ids from 0, a level filter picks levels (world:=hospital_2[0],three_storied_residential[1]), and every name gets the world name as prefix (hospital_1_, a second listing of the same world hospital_1_2_). The composed world is written to $ARENA_ASSETS_DIR/_composed/<list>/world.yaml and loads like any linked world, the elevator ring included. It has no scenarios, the worlds' own scenarios name the unprefixed entities.

Ceilings

ceiling: on a zone is false for an open zone or a mapping of height (top height in metres, derived when absent), material, cast_shadows (default false) and lights (the ceiling rig, see Lights). With no height, the ceiling sits on the tallest wall standing on or within 0.3 m of the zone polygon, at least 2.0 m.

Ceilings are opaque from below and transparent from above. Isaac hides them unless the world declares lights, sim.isaac.viewport.ceilings:=on|off forces them. The walls a zone with a ceiling borders or holds reach it, and a door lower than the ceiling gets a visual-only lintel above it. They are visual-only (no collision). With cast_shadows false the ceiling does not occlude the sun, so interiors stay lit without global illumination.

Doors and elevators

A doors: entry sits on the level: name, span: [[x, y], [x, y]], plus its kind, timing and material. Full field reference: Door fields.

An elevators: entry is one cabin on its level: name, position, size, door_side (inferred from the nearest wall when absent) and destinations, the names of the cabins a ride goes to, on any level, so a shaft is one cabin per level linking the others and two cabins on one level make a teleporting pair. Full field reference: Elevator fields.

A cabin with several destinations goes to the one in its writable semantic field target (a cabin name, e.g. set by a robot task or a scenario timeline). With target empty it cycles round-robin over the list, one step per departure. A written target stays until rewritten, writing "" returns to round-robin. A single-destination elevator publishes target: "".

Elevator capacity: <int> (default null = unlimited) caps the cabin: while the agents inside (robots plus pedestrians) are at or above it, outside calls are ignored, so the door closes after hold_time and the cabin departs with whoever is inside. Departures from several cabins to the same destination queue up and arrive in order. A rider whose teleport fails (refused by the sim, or not seen to move within 10 s sim time) is released: the source cabin unseals and retries the ride, the destination stops holding its door for them.

Elevator call_mode: level | edge (default level) sets how agents outside the cabin call it. level: any agent within activation_distance of the hall door calls every tick, so the door stays open (and a pending departure is cancelled) for as long as anyone waits there. edge: a call only (re)opens the door when an agent newly enters the call radius, or when the door is shut and no departure is pending (nobody new inside). Agents merely standing in the lobby no longer hold the door: it closes after hold_time (the slab safety reopen still applies) and the cabin departs. Use edge for crowded lobbies.

A door's activation_distance takes a scalar or a pair [a, b] (the larger value is the trigger radius).

Door kind is one of sliding (default, slab slides along the span), sliding_top (slab lifts), teleport (slab vanishes) and hinged: the slab rotates about the first span point by up to 90 deg over transition_time. swing_direction: 1 (default) opens towards the left normal of the span, swing_direction: -1 towards the right. Like sliding doors, a hinged slab never moves into an agent: an opening step that would hit someone in the swept arc holds, a close with anyone in the remaining arc is refused and the door re-opens (blocked_close).

doors:
- name: office_door
  span: [[2.0, 0.0], [2.9, 0.0]]
  kind: hinged
  swing_direction: -1
elevators:
- name: hub
  position: [5.0, 0.0]
  destinations: [elev_a, elev_b]
  capacity: 4
  call_mode: edge

Pedestrians see mechanisms as walls where they cannot pass: cabin back and side walls always, and a door's segment while it cannot open for them (a gate effectively locked with no authorized agent near, or activation_distance <= 0 with no pressed plate driving it).

Microphones

A level's optional microphones: list places audio listeners for the acoustics pipeline. Full field reference: microphones.

Semantic annotations

Door and elevator state is intrinsic: every spawned door/elevator publishes its full vocabulary with no annotation needed:

kind discrete continuous predicates members
door state progress open, in_transit, triggered, blocked_close (a close is refused because an agent is in the slab path) agents within the activation radius
elevator cabin_door, target (writable, see above) arriving_eta, occupants (riders committed to a ride), cabin_door_progress, live_occupants (agents inside now), capacity (-1 = unlimited) departing, in_transit, dispatched, just_arrived, full agents inside the cabin now

Agents are named as the runtime tracks them: robots by sim path, pedestrians by their ped name. semantics: on a doors:/elevators: entry is only for attaching a scripted kind (gate, pressure_plate) to that entry. Zones accept an optional semantics: list of literal state/predicate primitives:

zones:
- name: lobby
  polygon: [...]
  semantics:
  - {state: max_speed, value: 1.5}
  - {predicate: quiet, value: true}

An Elevator entry configures its fire-recall regime as a first-class field, not a semantics annotation:

elevators:
- name: 1_elevator
  position: [5.0, 0.0]
  destinations: [2_elevator]
  recall_on: alarm

Full field reference: worlds/README.md.

KG

What a world means, as opposed to what the runtime executes, lives in an optional worlds/$WORLD/kg.yaml beside world.yaml: a knowledge-graph scene of entities, facts and constraints (format: arena-kg/1), typed by the vocabulary packages in kg/packages and shared with the airchitect world generator.

format: arena-kg/1
uses: [core@2]
entities:
- {id: room:reception, props: {type: Main Entrance, description: A spacious reception and waiting hall}}
- {id: room:triage}
- {id: obj:desk_1, props: {label: Reception Desk}}
- {id: obj:kiosk_0, props: {label: Information Kiosk, placed: false}}
facts:
- {subject: room:reception, verb: contains, object: obj:desk_1}
- {subject: room:triage, verb: contains, object: obj:desk_1}
- {subject: room:emergency, verb: left_of, object: room:triage}
  • Ids are <kind>:<world name>: room:<zone>, door:<door>, obj:<object>, light:<light>, sound:<sound>, elevator:<cabin>, floor:<level id>. Those must name something in world.yaml, except an obj with placed: false (an object a generator asked for but never placed). Roles, regions, agents, profiles, purposes and materials live only here.
  • The KG carries no geometry (boundary, line, pose, model, ...): world.yaml owns it.
  • Membership is contains (a thing may sit in several zones), connectivity room connects room with door: door:<name>. Route instructions, the acoustic room graph and the prompt modes read them.
  • Facts are stated (source: generated, the default) or derived (source: derived). On load, derived facts are recomputed: a door belongs to the zones whose boundary it lies on and connects them, an object, light, sound or elevator to the zones holding its position. Only things no stated fact covers get derived ones, so a stated contains always wins. kg.derive:=all ignores the stated memberships and connections of things in world.yaml and derives them all, the rest of the KG stays. kg.derive:=off keeps the stated facts only, auto is the default.
  • A linked level brings the KG of its source world along: its entities, stated facts and constraints join this world's wherever what they name is part of this world, floor:<id> follows the level's new id, and this world's own KG wins every conflict. Saving writes only this world's own part back.
  • What people use a zone for is room has_purpose purpose:<work|break|transit|private|unknown> (package space@1). The scenario generator places its crowds by it, a params: {by: llm, ...} marks a model's proposal that a human has not reviewed, see generative/scenario.
  • Nothing in the KG drives the runtime. Mechanism semantics (presets, light_on, recall_on, zone states) stay in world.yaml.

M2 kinds, params, and timelines

M2 adds scriptable/derived kinds on top of the intrinsic door/elevator vocabularies and zone. Each kind takes its config under a params: dict on the primitive or preset item, which round-trips opaquely (omitted on serialize when empty):

kind attaches to preset expansion params keys
signal a world signals: entry state, phase_remaining, stop phases (list of {name, duration}), stop_phases, regime, optional stop line position ([x, y], level frame) and radius (evaluation only, ran_red_signal)
schedule a world schedules: entry state, active, window_remaining windows (list of {start, end, value}), default, regime
gate a doors: entry locked, blocked authorized (sim_paths/robot names), unlock_on, locked (initial value, default true: a gate authored without it starts locked and logs so)
pressure_plate a doors:/elevators: entry (own position) pressed position ([x, y], level frame like all geometry), radius, drives (a door name, <level_id>.<name> for a door on another level), latch, press_on, regime
occupancy_cap a zones: entry occupancy, cap, over_cap cap
sound a sounds: entry (level or scenario) sounding, volume_db sound_on, regime, sounding/volume_db (initial values)
light every lights: entry and every zone ceiling rig, attached from the light's own fields lit, dim, dead_fraction (rigs only) light_on

signal and schedule have no geometry, so they sit at world scope beside levels:. A sound needs a placement: exactly one of position, attach (name of an object of the world, whose yaw frame offset is applied in) or frame (a TF frame the sound rides, offset local to it, so frame: jackal/base_link is a speaker bolted to that robot). Other Sound fields: asset_id (required, the audio catalog entry), loop (default true) and reference_distance_m (default 1.0, must be positive, sets the falloff reference distance).

schedules:
- name: fire_alarm
  semantics:
  - {preset: schedule, params: {windows: [], regime: alarm}}
signals:
- name: crosswalk_light
  semantics:
  - {preset: signal, params: {phases: [{name: go, duration: 20.0}, {name: stop, duration: 10.0}]}}
levels:
- id: '0'
  sounds:
  - name: hall_siren
    asset_id: alarm_loop
    position: [4.0, 2.3]
    semantics:
    - {preset: sound, params: {sound_on: alarm, volume_db: 88.0}}

A preset params: key that names one of the preset's own primitives (volume_db, sounding, locked, ...) becomes that primitive's initial value instead of a shared param, so {preset: sound, params: {sounding: true, volume_db: 62.0}} is a radio that plays from the start at 62 dB. A top-level value: on a preset item that expands to more than one primitive is rejected at load: there is no single primitive it could mean. Presets are written back as authored.

A scenario may carry its own sounds: list with the same schema. Those are episode-scoped: attached at reset, gone at the next one, and their attach may also name one of the scenario's own objects:.

Lights

A light is one fixture, an ambient light, or a zone's ceiling rig. Each is a light semantic entity with lit (on/off), dim (dimming in 0..1) and, for rigs, dead_fraction (share of dead fixtures), all writable through semantics/set and the scenario timeline and recorded with every other kind. Worlds that declare no light render as before. A world that declares any light gets no default lighting at all, so what it declares is all there is and a blackout is dark. Lights that objects carry (below) do not count as declared, and neither do the lights a robot carries, so a lamp or a headlight adds its light on top of the default lighting.

Where Fixtures Placement
level lights: dome, sun none, a pose aims the sun
level lights: panel, tube, downlight, spot, bulb exactly one of pose, attach or frame, the latter two with an offset
zone ceiling: {lights: ...} panel, tube, downlight, spot, bulb a grid at spacing metres over the zone polygon, just below the ceiling
object annotation.yaml lights: panel, tube, downlight, spot, bulb pose in the object frame, moved, turned and scaled with every placement
scenario lights: panel, tube, downlight, spot, bulb as for level lights:, attach names one of the scenario's objects:, level the floor of a literal pose
robot model_params.yaml lights: panel, tube, downlight, spot, bulb frame of the robot plus offset, see robots/README.md

Light fields: name, fixture, lumens (local fixtures, per fixture for a rig) or lux (dome, sun), cct_K (default 6500), cast_shadows (default false), cone_deg (spot only, default 40), and one of light_on (a regime, ! negates) or lit (initial value, default true), plus dim (default 1.0). A ceiling rig takes fixture (default panel), spacing (default 2.4), lumens, cct_K, cast_shadows, light_on/lit, dim and dead_fraction, needs a ceiling, and is named after the zone. The same fixtures of a rig die at the same dead_fraction in every run, and raising it only adds dead ones.

pose places a fixture in the level frame, with a height, since a light sits above the floor, offset in the frame of the object it is attached to or of its TF frame. A fixture emits along the local -z axis of that pose, a panel or tube lying along its local x and y, so a pose without an orientation shines straight down and yaw turns a tube about the vertical. bulb and dome take no orientation, a sun takes a pose whose position is zero. A frame light follows its TF frame, so frame: jackal/base_link with offset: {x: 0.2, y: 0, z: 0.3, pitch: -1.42} is a headlight that looks ahead and slightly down. A scenario, robot or episode object light whose name a spawned light already holds is left out with a warning.

A world without lights can still run lit: world.lighting:=auto gives every zone that has a ceiling, no ceiling rig of its own and no level light placed inside its polygon a rig of 7200 lm panels at 2.4 m spacing, without touching the world file. The default world.lighting:=authored renders exactly what the world declares.

levels:
- id: '0'
  zones:
  - name: central_hallway
    polygon: [[8, 0], [12, 0], [12, 34.2], [8, 34.2]]
    ceiling: {lights: {fixture: panel, spacing: 2.4, lumens: 3600, light_on: "!blackout"}}
  lights:
  - {name: ambient, fixture: dome, lux: 5}
  - {name: exit_strip, pose: {x: 10, y: 34, z: 2.2}, fixture: tube, lumens: 300, light_on: blackout}
schedules:
- name: power
  semantics:
  - {preset: schedule, params: {windows: [{start: 60, end: 90, value: out}], regime: blackout}}

An object's annotation.yaml can carry lights: entries with name (default light), fixture (default bulb), pose, lumens, cct_K, cone_deg and glow. arena_assets writes them when it builds an object whose source model contains lights (hand-written entries win, and the built models carry no light themselves). glow names the material that glows with the light, a lamp shade for instance: Isaac, Gazebo and MuJoCo make it emit the light's color scaled by its dim, and it is dark while the light is off. Every placement of the object, in a level's objects:, a scenario's objects: or by an obstacle mode, then gets the light <entity>_<name> for as long as the placement exists, and a light: key on the placement sets its lit, light_on or dim:

objects:
- {name: desk_lamp, model: Residential/Desk_Lamp, pose: {x: 2, y: 1, z: 0.75}, light: {light_on: "!daylight"}}

A scenario dims the hall with {entity: central_hallway, field: dim, value: "0.3..1.0"} in its timeline. Isaac renders every fixture as an area light, and shades asset materials that only emit a texture by that texture, so they darken with the lights. Gazebo renders a rig as one shadowless point light per 2 by 2 block of fixtures, plus one unattenuated fill at the room center for the light the room surfaces reflect (40 % reflectance), since its renderer traces no indirect light. MuJoCo shades with at most seven lights per view and without shadows from fixtures, so each camera gets the fixtures merged the coarser the smaller they look from where it stands, plus the fill of the rig whose room it stands in. Dome and sun light the whole stage, so the first env's world sets them for every env of a runtime, and an env whose world declares other ones logs a warning naming both.

Pedestrian stimuli

A sound that propagation marks audible for a pedestrian reaches humansim as a stimulus named after the catalog category of its asset_id (alarm_loop has category: alarm, so its stimulus is alarm). An agent type opts in by declaring a need of that name and a transitions: entry on it, and humansim sets that need to 100 once the agent's sampled reaction_time has elapsed, so the transition fires. Agent types without such a need ignore the sound. See worlds/hospital_1/scenarios/fire_alarm_evacuation for the reference: the evacuee agent type idles until alarm rises, then walks to the static exit object.

gate and pressure_plate reuse an existing doors:/elevators: entry as their attachment point, occupancy_cap reuses a zones: entry, all via the same semantics: list. A gate should spawn unlocked (locked: false in its params): the world is shared by every scenario, so a gate that defaulted to locked would block scenarios that never touch its regime. A scenario that wants the door locked at episode start says so explicitly in its timeline: (see below), leaving the world itself inert:

doors:
- name: door_edge_1_1
  span: [[4.0, 1.55], [4.0, 2.45]]
  semantics:
  - {preset: gate, params: {authorized: [], unlock_on: alarm, locked: false}}
  - {preset: pressure_plate, params: {position: [4.0, 2.0], press_on: alarm, drives: door_edge_1_1}}

A regime (or its per-kind alias unlock_on/press_on) names a boolean asserted by a scripted kind's driving predicate. Other kinds (gate, pressure_plate) consult that name without a direct wire between the two entities. A sound consumes a regime the same way, via its sound_on alias, and a light via light_on. Every follower field accepts a leading ! for the negation, so light_on: "!blackout" is lit unless blackout is asserted. An elevator's recall_on field is the same regime-consult mechanism, just wired as a first-class Elevator field instead of a semantics: alias, since recall is mechanism configuration rather than published state. See the fire-alarm worked example below.

Scenario timelines

scenario.yaml accepts an optional timeline: list. Each entry fires its actions against exactly one trigger and needs at least one action:

trigger meaning
at: <seconds> fire once at episode second t
every: <seconds> fire at offset (default: one period in), then each period, optional until
when: {entity, field, is} fire on the false-to-true edge of a semantic value
when: "<atom>" fire on the false-to-true edge of a condition atom (see Add a scenario)

In a when atom the subject robot stands for every robot of the env: the atom holds while it holds for at least one of them, so not robot within 3 of 2,2 holds while some robot is farther than 3 m from (2, 2). A robot's own name (anonymous robots are named after their model, e.g. jackal) judges only that robot. Without a placed robot such an atom does not hold.

action meaning
set: [{entity, field, value}] write semantic values
stimulus: [{agents, need, intensity}] raise need on each pedestrian named in agents through humansim's notify_stimulus, intensity 1 by default (humansim sets the need to 100 x intensity)
timeline:
- at: 0.0
  set:
  - {entity: door_edge_1_1, field: locked, value: "true"}
- at: 12.0
  set:
  - {entity: fire_alarm, field: active, value: "true"}

The world's door_edge_1_1 spawns unlocked, and this scenario locks it at t=0 so the corridor starts sealed. At t=12s the fire_alarm schedule's active predicate goes true and asserts regime alarm: a gate with unlock_on: alarm reports locked=false, a pressure plate with press_on: alarm reports pressed=true and holds its drives door open, and an elevator with recall_on: alarm refuses calls and holds its cabin door open, all as pure regime consults with no per-effect timeline entry. See worlds/three_storied_residential/scenarios/fire_alarm/scenario.yaml for the reference timeline.

A stimulus drives behavior-tree agents whose agent type declares the need and a transition on it, as for pedestrian stimuli. Here edge_0 waits until the robot comes within 4.2 m of (12.1, 4.0) and the d_* group switches at t=8s:

timeline:
- when: "robot within 4.2 of 12.1,4.0"
  stimulus: [{agents: [edge_0], need: go, intensity: 1.0}]
- at: 8.0
  stimulus: [{agents: [d_1, d_2, d_3], need: form}]

Every agent of a stimulus is one of the scenario's humans, loading fails on any other name. With the arena human backend loading also fails when the agent type of an agent (its agent.agent_type) neither declares the need nor waits for it as the until event of an autonomous step. A stimulus the human simulator does not take at runtime is logged and skipped, the other agents still get theirs. Only the arena human backend acts on stimuli: under any other backend a scenario with stimulus entries fails to load with an error naming human:=arena.

Every semantic snapshot of the env carries a timeline entity (kind timeline). Its discrete field stimulus.<agent>.<need> holds the sim time in seconds of the tick on which that stimulus was first taken by the human simulator in the episode, and a stimulus that never landed has no field.

3. Generate the map

touch_world my_world

This reads worlds/my_world/world.yaml and writes a preview map per level, map/<level id>.png and map/<level id>.yaml. The runtime does not need them, it renders its own map from world.yaml.

To regenerate with visible static obstacle footprints:

touch_world my_world \
    --assets red \
    --assets-label blue \
    --assets-bbox "((-.5, .5), (-.5, .5))"

To rewrite world.yaml itself in canonical form (short forms, rounded numbers, defaults left out) with its kg.yaml:

touch_world my_world --all

touch_world accepts --resolution <float> (metres per pixel, default 0.05).

Format 1 worlds

Worlds written before format 2 (one <level id>/world.yaml of zones: per level, walls, doors, objects and lights nested in zones) still load, with a deprecation warning, through the legacy loader in tree/World/legacy.py. It hoists zone walls, doors, elevators, entities, sounds and lights to the level, schedules and signals to the world, turns destination: "1.a" into destinations: [a], drops exact repeats, renames different things that share a name, and keeps zone descriptions and which zone listed which thing in the KG. A graph.yaml beside a format 1 world (the airchitect scene graph) is lifted into the KG too: a node becomes a room with its type, description and attributes, a wish-list asset an unplaced obj, and an edge a connection through the door named door_<edge id> or door_<a>__<b> plus its position facts (an edge with neither door states no connection, geometry derives it). Migrating writes it into kg.yaml and removes graph.yaml. Format 1 scenarios (static:/dynamic:, the obstacles: wrapper, goal:, start_floor) load the same way. Rewrite a world and its scenarios in place with:

touch_world my_world --migrate

Nothing writes format 1.

4. Add a scenario

Create worlds/$WORLD/scenarios/default/scenario.yaml:

format: arena-scenario/2
robots:
  - start: [1.0, 1.0, 0.0]
    phases:
      - {goto: [8.0, 8.0, 0.0]}

An empty scenario (robot start and phases only) is valid. Phases run in order and are {goto: <pose or zone/door/elevator/pedestrian name>}, {gesture: <name>}, {reach: <named target | random | pose>}, or a hold phase with neither (the robot stays where it is), or {loop: [<phases>], until: "<...>"} (the phases repeated in order until the until holds, which drops the running phase and continues after the loop). Every phase takes until: "<clause>[ or <clause>...]", a clause being <atom>[ and <atom>...][ for <seconds>] (completes once any clause has held, every atom together, for its dwell, after arrival for a goto, so caller plays beckon for 0.5 or robot within 2.5 of caller), hold_time (park seconds on a goto), signal (the robot ends the goto itself by sending that signal, such as arrived, and is judged against the tolerance at that moment), conditions: (clauses judged over this phase only), text: (the instruction handed to language-conditioned planners, instruction: is accepted as the same key) and on_failure. A robot entry may also carry level (the level it starts on), goal_level (the level its goto phases aim at) and conditions: judged over its whole phase list.

robots:
  - start: [1.0, 1.0, 0.0]
    phases:
      - {goto: kitchen}
      - {until: "not visitor_1 in kitchen"}
      - {goto: sofa, hold_time: 2, conditions: [{op: never, p: "robot in hallway"}]}
      - loop:
        - {goto: [10.0, 18.0, 1.5708]}
        - {goto: [10.0, 2.0, -1.5708]}
        until: "robot sees caller within 12 and caller plays beckon for 0.5"
      - {goto: caller, tolerance_radius: 2.5, hold_time: 0}

Atoms are the conditions: grammar of worlds/README.md: <entity>.<field> == <value>, <subject> in <zone>, <subject> within <r> of <subject>, <subject> sees <subject> [within <r>] (in range, default 12 m, with no wall of the rendered wall grid between them), <ped> plays <clip> and not <atom>. Either within subject may also be a point x,y (no spaces), in the frame of a scenario pose without level, so robot within 4.2 of 12.1,4.0. A goto to a pedestrian by name is judged by tolerance_radius around the pedestrian.

Add static obstacles under objects: and pedestrians under humans:, with the same schema as a world level's lists plus level (the level an entry stands on, multi-level worlds). An object's humansim: block (type, capacity, satisfies, interaction_radius, formation, seats) is forwarded to humansim as its WorldObject, so a go_to step or an interaction target: can name the entry by type.

For HumanSim (arena_humansim) pedestrians include an agent: block. The agent_type is either a built-in type (adult, elder) or a path-relative agent-type YAML file (./doctor.yaml) defining scripted behavior (sequences, interactions). waypoint_mode (repeat, reverse, once, random) sets how the waypoints replay, and a hunav: block carries the HuNav backend's own keys:

humans:
- name: agent_1
  model: female_adult_medical_01
  agent: {agent_type: adult, desired_velocity: 1.2}
  pose: [2.0, 5.0, 0.0]   # yaw in radians
  waypoints:
  - [2.0, 5.0]
  - [8.0, 5.0]

A scenario can also carry regions: (named pedestrian source/sink spawners for arena_humansim) and conditions: (episode success/failure clauses). Its sounds:, lights: and timeline: keys are the ones covered above. Scenario lights count as declared, like the world's, and go at the next reset, and so do its regions. See worlds/README.md for the full key list of every top-level key.

Robots: floors, elevator rides, mechanism map

Robot poses are level-local when the robot entry carries level / goal_level (the level ids of a multi-level world): start is shifted by level's origin, every goto by goal_level's. A pose given as a zone/door/elevator name is resolved on the merged world and is never shifted. When a goto lies on another level than the robot, elevator rides are inserted automatically (shortest ride sequence over every elevator's destinations).

via: on a goto phase lists elevators to ride explicitly, in order, before heading for the pose. This is how a robot rides a same-level pair such as elevator_ground -> elevator_top. An entry is an elevator name, or {elevator: <name>, to: <destination>} to pick one destination of a multi-destination elevator (default: the destination cabin closest to where the robot heads next).

robots:
  - start: [2.0, 5.0, 0.0]
    phases:
      - {goto: [31.0, 22.0, 0.0], via: [elevator_ground]}
  - start: lobby
    level: "0"
    goal_level: "2"
    phases:
      - goto: [4.0, 4.0, 0.0]
        via: [{elevator: hub, to: east_lift}]

Each ride is its own phase: the robot drives into the boarding cabin, stays there until the elevator teleports it, and continues once it stands in the destination cabin. The ride is judged like any other goto, with the boarding cabin as its target and robot in <destination cabin> as its until atom, and a robot waiting in the cabin does not count as stalled. For a multi-destination elevator the robot writes the elevator's target field (the destination cabin, e.g. east_lift) when the ride starts and again when it boards, and writes "" (back to round-robin) when it arrives. Single-destination elevators are never written.

The planner map is blind to door state by default: doors and elevators are painted free and a closed door is only seen by the robot's sensors. Setting the scenario task param mechanism_map: true (suite stage config: {scenario: {mechanism_map: true}}) serves the robot's planners a map in which every door the robot cannot open right now is occupied, through the env's normal map_server (LoadMap, same topic as the static map). A door counts as unopenable when its activation_distance is <= 0, or when it carries a gate that is effectively locked and does not list the robot (robot name or sim path, as in the gate's authorized) and it is not open or opening at the moment (a plate, regime or authorized pedestrian may hold it open). It is freed again as soon as that changes. With several robots in one env, one authorized robot frees the door for all. Benchmarks keep the default (false) unless the suite is about mechanism-aware planning.

Pedestrians and world mechanisms

Everything in this section needs the humansim backend (human:=arena); other backends ignore the extra keys and walk the plain waypoint list.

Waypoints. A humans: waypoint is a pose list ([x, y], [x, y, z]), a zone/door/elevator name, or a dict with exactly one of zone:, elevator: or flat x/y[/z], plus optional level: (the coordinates are then level-local) and dwell: (seconds to stand there before walking on). {elevator: <name>} is a ride: the ped walks to the cabin centre, waits inside until the elevator teleports it, then continues with the next waypoint. When two consecutive waypoints (or the ped's start and its first waypoint) lie on different levels, the boarding cabins of the shortest elevator route are inserted as rides automatically. For a multi-destination elevator the boarding ped writes the elevator's target (the destination toward its next waypoint) when it reaches the cabin centre. A ped holding for a ride never sees the next level's waypoint, so it stays in the cabin however long the elevator takes. waypoint_mode: reverse becomes an explicit round trip with rides both ways, repeat also routes the way back to the first waypoint, random with dwell or rides walks the list in order.

humans:
- name: commuter
  model: arenian
  pose: [2.0, 3.0, 0.0]
  agent: {agent_type: adult}
  spawn_delay: 4.0                          # appears 4 s into the episode
  waypoint_mode: once
  waypoints:
  - {x: 6.0, y: 3.0, dwell: 5.0}            # wait at the board for 5 s
  - {elevator: elev_ground}                 # same-level pair: explicit ride
  - {x: 4.0, y: 4.0, level: "2"}            # other level: rides inserted

spawn_delay: <s> (default 0) keeps the ped out of the simulation (engine, roster, physics sim) until that many sim seconds after the episode's spawn.

Mechanism state as stimuli. Every discrete or predicate change of a door, elevator, signal, schedule, gate, pressure plate or sound reaches every ped as the stimuli <entity>.<field>==<old> at 0 and <entity>.<field>==<new> at 1, after each ped's sampled reaction_time. Entity names are authored (no env prefix, no level suffix), the atom is the conditions: atom without spaces: elev_a.cabin_door==open, door_a.state==closed, gate_1.locked==true, sig_1.state==red. Continuous fields (progress, eta, occupants) are not sent. An agent type reads one as a need pinned at 0 (it then holds 100 while the state lasts) and waits on it with a transition. A ped spawned later starts from the current values. until: on an autonomous step can name the same atom, it fires when the state is entered.

needs:
  "elev_a.cabin_door==open":
    initial: {mean: 0.0, clip_low: 0.0, clip_high: 0.0}
    decay_rate: {mean: 0.0, clip_low: 0.0, clip_high: 0.0}
sequences:
  wait:
    steps: {stand: {duration: {mean: 3600.0}}}
    then: wait
    transitions:
    - when: {"elev_a.cabin_door==open": {above: 50.0}}
      goto: board

To reuse one agent type for several elevators, give the humans: entry semantic_alias: {elevator: elev_a}: that ped also receives elevator.<field>==<value> for elev_a, and the agent type keys on "elevator.cabin_door==open". A ped an elevator teleports receives the stimulus teleported at 1 (declare it with a fast decay_rate for a pulse).

World objects. Every door is a world object named after it with type door at the doorway centre. Every elevator is a world object named after it with type elevator at the cabin centre, plus <name>/queue of type elevator_queue in the cabin doorway with a line formation that grows out into the lobby (first slot 0.7 m outside the door, 0.8 m apart). A go_to or QUEUE_USE step targets them by name or, by type, the nearest one.

Shipped agent types (configs/agent_types/mechanisms/, from a scenario directory ../../../../configs/agent_types/mechanisms/<file>):

  • elevator_rider.yaml: QUEUE_USE on the nearest elevator_queue, board the nearest elevator when elevator.cabin_door==open (needs semantic_alias: {elevator: <name>}), back into the line if the door closes first (20 s patience), wait in the cabin, after teleported walk to the nearest marker of type elevator_exit. The whole line is released at once when the door opens.
  • door_holder.yaml: walk to the nearest door, stand in the doorway for about 8 s (4 to 14), then walk to the nearest marker of type door_holder_exit.
humans:
- name: rider_1
  model: arenian
  pose: [2.0, 3.0, 0.0]
  semantic_alias: {elevator: elev_a}
  agent: {agent_type: ../../../../configs/agent_types/mechanisms/elevator_rider.yaml}
objects:
- {name: exit_a2, model: Common/Object/painting_1, pose: [21.0, 5.5, 0.0], humansim: {type: elevator_exit}}

A marker is any objects: entry with a humansim: {type: ...} block (a wall painting, as the exit of the fire-alarm scenario). Peds walk to its pose, so put it in reach.

5. Add world-local assets (optional)

Assets placed under worlds/$WORLD/assets/<domain>/<Type>/<name>/ are resolved before shared-local ($ARENA_ASSETS_DIR_LOCAL), imported ($ARENA_ASSETS_DIR_IMPORTED) and network-fetched assets. <domain> defaults to Common if you have no reason to scope the asset further. Five asset kinds can be shipped this way:

Type Directory contents Referenced as
Object 3D model files (SDF and/or USD), optional annotation.yaml with a bounding_box model: on an objects: entry
Human 3D model files (SDF) model: on a humans: entry
Material <name>.mdl plus its texture files a zone floor, a ceiling material, a wall or door material
Wall <name>.yaml, a WallDescription (see configs/walls/README.md) kind: on a walls: entry or a zone outline
Sound <name>.yaml (a sound manifest, see configs/sounds/README.md) plus its wav files asset_id: on a sounds: entry

To ship a custom wall style:

mkdir -p worlds/$WORLD/assets/Common/Wall/my_style
# create worlds/$WORLD/assets/Common/Wall/my_style/my_style.yaml

Then reference it in world.yaml walls as kind: my_style. The other four kinds follow the same assets/<domain>/<Type>/<name>/ layout. Objects, humans and materials carry model or material files instead of a preset YAML, a sound carries its <name>.yaml manifest beside the wav files.

A wall style can also carry a captured look: a 3D Gaussian splat that Isaac renders in front of the wall. The splat wall example shows the wall kind, the arena_assets README how to build the asset. A captured object (arena-assets splat --object) is placed like any other object, as an objects: entry. Isaac renders it and its sensors see it. It is visual only, so robots drive through it, unless the bundle carries a collider.

Any object bundle can carry a collider mesh that annotation.yaml names under collider:. It is baked into the bundle's USD and SDF models, where it takes the place of the visual meshes as the collision. Without one, objects collide with their visual meshes in Isaac and Gazebo alike. arena-assets collider attaches one to any object bundle, see Colliders.

6. Generate with the AI pipeline (alternative)

scripts/generate_world posts a natural-language prompt to a generation server and extracts the resulting zip into a world directory:

generate_world "A hospital floor with 4 patient rooms and a central hallway" \
    --endpoint http://localhost:5501 \
    --outdir my_world

--outdir is resolved relative to <arena_simulation_setup share>/worlds/ if it is not an absolute path. The server must be running separately; this script is a thin client.

After generation, run touch_world my_world to ensure map/ is current.

7. Validate

from arena_simulation_setup.tree.World import WorldIdentifier

world = WorldIdentifier('my_world').resolve_sync().load()
for level_id, level in world.levels.items():
    print(level_id, f"{len(level.zones)} zone(s), {len(level.all_walls)} wall segments")

This raises FileNotFoundError if world.yaml is missing and ValueError naming the YAML path if it is malformed. The JSON Schema of the KG comes from arena_simulation_setup.kg.schema_for.

List all scenarios:

view = WorldIdentifier('my_world').resolve_sync()
for scenario_id in view.scenario.listall():
    print(scenario_id.name)

Load a scenario:

scenario = view.scenario('default').resolve_sync()
print(scenario.load())

Reachability (NAV@r)

nav_at_r reports how much of the floor a robot of footprint radius r can reach, the NAV@r metric of the IndoorBench paper:

ros2 run arena_simulation_setup nav_at_r my_world
ros2 run arena_simulation_setup nav_at_r my_world --robot jackal --walls-only
ros2 run arena_simulation_setup nav_at_r /path/to/world --radius 0.3 --start 2.0,3.5 --json

--image map.png paints the result onto the map: the dominant component in green, every stranded component in another color, free cells with clearance below r in light grey, entities in brown and walls in black.

Each level is rasterized at 0.05 m per cell with the map rasterizer, walls plus the footprint of every static entity (its bbox, else the bounding_box of its model's annotation.yaml, entities whose box starts above 2 m are skipped). A cell is traversable when its clearance is at least r (default 0.267 m, the Jackal radius), and traversable cells group into 8-connected components. With m_i the share of traversable cells in component i and w_i its share of spawn cells (clearance of at least 0.45 m), NAV@r is sum_i w_i * m_i: 1.0 when every traversable cell is reachable from every spawn, lower the more floor is stranded in other components.

Column Meaning
NAV@r the expectation above, n/a when no cell admits a spawn
components number of traversable components
largest share of traversable cells in the largest (dominant) component
start with --start X,Y (level frame, meters): share of the component holding the start, snapped to the nearest traversable cell
no bbox static entities without a resolvable bounding box, out of all static entities. They are not rasterized
cut-off zones zones of at least 2 m2 with no cell in the dominant component although they are traversable on walls only

--walls-only drops the static entities. A zone that stays cut off there has a layout fault (a missing, misplaced or too narrow door), one that is cut off only in the furnished run is blocked by furniture. --robot takes a robot name from the installed arena_robots package or a robot directory and reads radius from its caps/mobile.yaml. A world name resolves like at runtime, and name[0,1] restricts the levels.

The same numbers are available in Python:

from arena_simulation_setup.metrics.nav_at_r import nav_at_r, resolve_world

view, levels = resolve_world('my_world')
for level_id, result in nav_at_r(view, radius=0.267, levels=levels).items():
    print(level_id, result.value, result.components, result.cut_off_zones)