Skip to content

Mission Authoring Guide

This guide covers every field in the mission YAML schema. It is aimed at fl-base-pack contributors who want to author missions for Fighters Legacy.


Overview

A mission file is a YAML document that the engine loads into a MissionData asset via FolderContentPack. The engine reads it at mission start; no runtime reloading occurs. Missions can be standalone or embedded in a campaign's story sequence.

Files live in missions/ inside the content pack directory.


Required top-level fields

name: "Storm Warning"
map: ukraine
layer: ukraine_clear
time: { hour: 14, minute: 0 }
wind: { heading: 270, speed: 12 }
sides: [nato, russia]
objects:
  - ...
triggers:
  - ...
Field Type Constraints Description
name string non-empty Display name shown in mission select and after-action screens
map string non-empty Terrain asset ID — must match a file in terrain/
layer string non-empty Weather/lighting layer ID (e.g. ukraine_clear, ukraine_overcast)
time.hour int 0–23 Local mission start hour
time.minute int 0–59 Local mission start minute
wind.heading int 0–359 Wind-from heading in degrees (0 = north, 90 = east)
wind.speed float ≥ 0 Steady-state wind speed in m/s. Gusts are added by the engine on top of this value.
sides sequence ≥ 1 element Coalitions active in this mission — see Coalitions below. Each entry is a side id (string) or a mapping with an id and optional allies
objects sequence ≥ 1 element Unit and aircraft placements — see Objects section
triggers sequence Win/loss/event conditions — see Triggers section

Optional top-level fields

Field Type Default Description
weather.preset string clear Initial weather: clear, partly_cloudy, overcast, rain, storm, snow, blizzard. Gust amplitude and turbulence intensity scale with the preset. snow and blizzard produce snow precipitation at any altitude.
time_scale float server default (10) Game seconds per real second for this mission. Overrides [world] time_scale in server.toml. Use 1.0 for real-time cinematic missions.
airspace_zones sequence none Restricted airspace the server enforces. See airspace_zones.

Example:

weather:
  preset: partly_cloudy
time_scale: 10.0   # omit to use server default


Coalitions

sides declares the coalitions in the mission. In the simplest form it is a flat list of ids, and every side is hostile to every other:

sides: [nato, russia]

To make sides allied, give an entry as a mapping with an allies list. Allied sides are friendly to each other; any side not in your allies list is hostile. Alliances are symmetric — declaring ukraine as an ally of nato makes the pair friendly in both directions.

sides:
  - id: nato
    allies: [ukraine]   # nato + ukraine are friendly; both are hostile to russia
  - id: ukraine
  - id: russia
Field Type Required Constraints Description
id string yes (map form) non-empty Coalition id; referenced by each object's side
allies sequence no each must name another side in sides Sides this coalition is friendly with
alert string no peacetime | elevated | conflict | war_state Starting airspace readiness posture (default peacetime). See airspace_zones.

An ally that names an unknown side is a hard error; listing yourself as your own ally is ignored with a warning. The engine loads these into the faction registry so AI decides friend-from-foe by the coalition graph, not by a crude "different team = enemy" rule.


objects entries

Each entry in the objects list places one entity in the world at mission start.

objects:
  - type: F22
    id: player1
    side: nato
    pos: [12400, 0, 8800]
    heading: 90
    alt: 500

  - type: SA10
    id: sam1
    side: russia
    pos: [15000, 0, 9000]
    heading: 0
Field Type Required Constraints Description
type string yes non-empty Aircraft or unit type ID — must resolve to a TOML asset in aircraft/ or entities/
id string yes unique across all objects in the file Internal ID referenced by triggers and Lua scripts
side string yes must appear in top-level sides list Coalition this unit belongs to
pos sequence yes exactly 3 numbers: [x, y, z] in metres World-space spawn position. Y is up; sea level ≈ 0
heading float yes Initial heading in degrees (0 = north, clockwise)
alt float no Altitude above sea level in metres; overrides pos[1] if both given
speed float no ≥ 0 Initial airspeed in m/s along the object's heading. Absent = a sane cruise default so an airborne object is in stable flight at spawn (an aircraft dropped in at 0 airspeed departs controlled flight); set 0 for a stationary start. Applies to AI objects and player slots alike. Ignored (forced to 0) on a start: ground object.
start string no air | ground air (default) drops the object in at its alt/pos[1] with a cruise airspeed. ground spawns it parked on the terrain — placed at ground level, idle throttle, zero airspeed, held stable until the pilot throttles up and rotates. Use ground for a runway/ramp start.
player bool no default false When true, this object is a joinable player slot: the engine does not spawn it as an AI/world entity — a connecting pilot is assigned to it and inherits its faction, spawn position, and aircraft type. Declare more than one for multiplayer.
ai string no Attach an AI controller (ignored on a player slot). See Scripted bots below.
route sequence no each entry is [x, y, z] A waypoint list the object flies. Takes precedence over ai when both are given.
loadout sequence of strings no each store must be in that station's allowed list Per-station weapon override of the entity's default payload (see Scripted bots).
crew mapping no see Crew configuration Bot skill ranges and per-seat overrides for a multi-crew aircraft. Ignored on a player slot.

Object IDs must be unique — the validator will reject duplicate IDs within a single file.


Crew configuration

A multi-crew aircraft (one that declares [[crew]] seats — see the entity format) can have its bots configured per mission via a crew: block on the object. The engine rolls each bot seat's per-instance skill deterministically from the mission name (seeded so a replay is byte-identical and two missions differ), within the configured range — a flight of bombers is not uniformly deadly.

- type: fl-base:b17
  id: bomber1
  side: allies
  pos: [40000, 3000, 12000]
  heading: 270
  ai: "waypoint 40000 3000 12000"
  crew:
    skill: [0.3, 0.8]        # aircraft-level range every bot seat rolls within (or a single number = fixed)
    seats:
      - role: tail-gunner    # name a seat by `role` OR by `seat: <index>` (exactly one)
        skill: 0.9           # per-seat skill override (fixed or [min, max]); wins over the aircraft range
        bot: builtin:gunner  # override the seat's bot spec
      - seat: 2
        empty: true          # spawn this seat empty (it contributes no fire) instead of a bot

crew: fields:

Field Type Notes
skill number or [min, max] Aircraft-level skill range in [0, 1] all bot seats roll within. A single number is a fixed skill.
seats sequence Per-seat overrides.
seats[].seat int Seat index to override. Exactly one of seat / role must be set.
seats[].role string Seat role to override (resolved to an index against the entity's [[crew]]).
seats[].skill number or [min, max] Per-seat skill override (wins over the aircraft range).
seats[].bot string Override the seat's bot spec (gunner / builtin:gunner / lua:<script>).
seats[].empty bool true spawns the seat empty (no bot).

validate-mission --pack <dir> cross-checks a crew: block against the referenced entity type's declared [[crew]]: a seat/role the entity does not have, or a crew: block on a single-seat entity, is an error.


Scripted bots

An object can be given a brain, a route, and a custom loadout so a mission stands up armed adversaries without a human at the stick.

objects:
  - type: fl-base:f5e
    id: bandit
    side: red
    pos: [22000, 0, 18000]
    alt: 4500
    heading: 225
    ai: "lua fighter"                       # a Lua behavior from the pack's ai/ directory
    loadout: [fl-base:m39a2, fl-base:aim9p, "~"]   # gun + one wingtip missile; strip the other rail
    route:
      - [22000, 4500, 18000]
      - [10000, 4500, 6000]

ai

A controller spec, using the same grammar as the spawn --ai admin command:

  • lua <script> — a Lua behavior loaded from the content pack's ai/ directory (by asset name).
  • a C++ behavior name plus arguments — e.g. pursuit <idx>, loiter <cx> <cy> <cz> [radius] [alt], patrol_attack <idx>, escort <idx>. See the AI controller factory for the full list.

route

A list of [x, y, z] waypoints the object flies via a waypoint-following controller. When both route and ai are present, route wins (the object flies its waypoints); the validator warns.

loadout

Overrides the entity's default payload station by station. Each entry names the store on that station in hardpoint order; ~, -, or an empty string leaves a station empty, and listing fewer stores than the aircraft has stations keeps the remaining stations at their defaults. A store that is not in the station's allowed list (or is an unknown weapon id) is refused and the station is left empty. ai, route, and loadout on a player: true slot are ignored (a human flies it) — the validator warns.


triggers entries

Triggers define events that fire during the mission. The engine evaluates the on predicate server-side at a second-scale cadence (not every 60 Hz tick) and executes the do action once, the first time it becomes true. Triggers are evaluated in the order they appear in the file.

triggers:
  - on: destroy(sam1)
    do: mission_success

  - on: timer(300)
    do: mission_failure

  - on: mission_start
    do: spawn(Su27,russia,[15500,0,9200])
Field Type Required Description
on string yes Trigger predicate — see supported forms below
do string yes Action to execute when predicate becomes true — see supported forms below

Supported on predicates

Form Description
destroy(<id>) Fires when the object with the given ID is destroyed. id must exist in objects.
mission_start Fires once, immediately when the mission begins.
timer(<seconds>) Fires after the given number of seconds have elapsed since mission start.

Additional predicates may be available to Lua scripts via world.on_trigger(). The validator only checks the forms listed above; unknown predicate strings are passed through without error.

Supported do actions

Form Description
mission_success Ends the mission as a success (a terminal objective action).
mission_failure Ends the mission as a failure (a terminal objective action).
set_weather <preset> Transitions the weather to <preset> (clear/partly_cloudy/overcast/rain/storm/snow/blizzard). Example: do: set_weather storm on an objective-destroy trigger.
set_time <hours> Sets the in-game time of day (0–24, float). Example: do: set_time 20.5.
spawn(<type>,<side>,<pos>) Spawns a new unit of type for coalition side at world position pos (x,y,z comma-separated, no spaces). Example: spawn(Su27,russia,15500,0,9200)
detonate <x> <y> <z> <radius_m> <damage> An AoE warhead at a world point (linear falloff; add --nuclear for the EMP ring). A scripted flak/explosion event. Example: do: detonate -880 1800 0 600 30.
atc_scramble <airport> <type> [count] Launches count AI departures of type from a named airport in ATC-sequenced order. Example: do: atc_scramble builtin:airfield builtin:debug-entity 1.

Non-terminal actions (everything except mission_success/mission_failure) are routed server-side through the same validated command path the admin console uses — a mission can do exactly what an operator could, and nothing more. Unknown action strings are logged and skipped (or handled by a Lua script's world.on_trigger()), never a hard error.


airspace_zones — restricted airspace (optional)

The optional airspace_zones: block declares regions of airspace a coalition enforces. It is server-authoritative: an aircraft of another side that enters a zone starts a dwell timer and escalates through warnedinterceptors scrambledweapons free the longer it stays. Clients are told about posture changes but decide nothing.

A zone is a shape in the XZ plane crossed with an altitude band — the same planar (x, z) vocabulary [spawn] points use. Circles take a center/radius; polygons take a convex vertices ring. (center is written [x, y, z] for symmetry with an object's pos, but the Y component is ignored: alt_floor/alt_ceiling own the vertical extent.)

sides:
  - { id: nato }
  - { id: russia, alert: elevated }   # starting posture; default peacetime

airspace_zones:
  - id: capital_airspace
    type: circle
    center: [15000, 0, 8000]   # world XYZ; the zone test is in the XZ plane
    radius: 5000               # metres
    alt_floor: 0               # metres above the datum
    alt_ceiling: 12000
    owner: russia              # the coalition that enforces the zone
    policy: military_intercept # a zones/<id>.toml in the content pack

  - id: border_region
    type: polygon              # convex ring in XZ
    vertices: [[0,0],[10000,0],[15000,5000],[10000,10000],[0,10000]]
    alt_ceiling: 999999
    owner: russia              # no `policy:` — the builtin default posture applies
Field Type Applies to Required Default Description
id string all yes Zone id, unique within the mission; referenced by Lua zone queries
type string all yes circle | polygon
owner string all yes The enforcing side; must appear in sides
center [x,y,z] circle yes Centre in world metres; the Y component is ignored
radius number circle yes Zone radius in metres (> 0)
vertices sequence polygon yes ≥ 3 [x, z] pairs forming a convex ring
alt_floor number all no 0 Band floor, metres above the datum (inclusive)
alt_ceiling number all no 999999 Band ceiling, metres (inclusive); must exceed alt_floor
policy string all no builtin default Escalation policy id — a zones/<id>.toml in a content pack

How escalation works

Every zone owner has an alert levelpeacetime, elevated, conflict or war_state — set by sides[].alert and changed at runtime by world.set_alert_level(). The owner's current level picks which row of the zone's escalation policy applies, so raising a coalition's alert level tightens every zone it owns at once without editing a single zone.

Within a zone an intruder passes through five stages: cleanin_zonewarnedintercepthostile. Dwell thresholds are cumulative from the moment of entry, and a threshold of 0 means the stage is already satisfied on entry — which is how the war_state row means "weapons free the instant you cross the line, no warning". Two shortcuts apply: a side already hostile to the owner (per the coalition graph) is weapons-free on entry regardless of dwell, and the owner is never an intruder in its own airspace.

Leaving the zone fires a zone-exit event. Whether that helps is the policy's call: with compliance_reset the zone forgets the intruder after a cooldown, so turning back works; without it the stage sticks, which is the point of a wartime posture. Lowering the alert level relaxes the schedule for the next intruder — it never revokes a weapons-free call already made on this one.

A zone whose owner does not name a side is a hard error at parse time. A zone whose policy names a file the loaded packs do not ship is not an error: it falls back to the builtin default posture, so a missing pack degrades the zone rather than silently switching enforcement off. Escalation policy TOML is documented in formats.md.

Zone state is readable from Lua via world.get_zone_stage(entity_idx, zone_id) and world.is_in_zone(entity_idx, zone_id); see ai.md.


cameras — cinematic shots (optional)

The optional cameras: block scripts camera shots for the cinematic demo-recording pipeline (docs/developer/demo-recording.md). It is presentation-only: the server parses and ignores it, and the recording client's ShotDirector consumes it to drive the camera while recording. A normal single-player or multiplayer session ignores it entirely.

Shot times are in sim-seconds from mission start. Shots must be listed in ascending start order and must not overlap (a shot occupies [start, start + duration)); a gap between shots holds the previous shot's final pose. There are four shot types:

cameras:
  shots:
    - { type: static, start: 0,  duration: 8,  pos: [1200, 250, -300], look_at: player1, fov: 60 }
    - { type: orbit,  start: 8,  duration: 12, target: bandit1, radius: 400, height: 60, period: 30 }
    - { type: chase,  start: 20, duration: 15, target: player1, offset: [-60, 15, 0],
        stiffness: 4.0, look_at: bandit1 }          # offset is in the target's body frame [aft, up, right]
    - { type: move,   start: 35, duration: 7,  look_at: player1, ease: smooth,
        keyframes: [ { time: 0, pos: [800, 300, -100] }, { time: 7, pos: [-200, 280, 150] } ] }
Field Type Applies to Required Default Description
type string all yes static | orbit | chase | move
start number all yes Sim-seconds from mission start (≥ 0)
duration number all yes Shot length in seconds (> 0)
fov number all no 60 Vertical FOV in degrees, in [20, 120]
look_at id or [x,y,z] all static/move: yes; orbit/chase: no target An object id or a fixed world point
pos [x,y,z] static yes Fixed eye position, world metres, Y up
alt number static no MSL altitude override of pos[1]
target id orbit, chase yes The tracked object id (cross-checked against objects)
radius number orbit no 300 Orbit radius in metres
height number orbit no 50 Orbit height above the target in metres
period number orbit no 30 Seconds per revolution; negative = clockwise
offset [aft,up,right] chase no [-60, 15, 0] Eye offset in the target's body frame
stiffness number chase no 0 Exponential eye-smoothing rate (1/s); 0 = rigid
keyframes sequence move yes ≥ 2 { time, pos } entries; time is relative to the shot start
ease string move no linear linear or smooth (Catmull-Rom when > 2 keyframes)

target and any look_at that names an object id are cross-checked against objects (an unknown id is a hard error, the same rule as destroy(<id>) triggers). A cameras-only sidecar document (a YAML doc containing just a cameras: block, used with the recorder's --shot-track override) parses through the same entry point; because it declares no objects, its id cross-references are not checked. All camera errors accumulate like the rest of the schema.


Cross-references between triggers and objects

If a trigger's on predicate references an object by ID (e.g. destroy(sam1)), the validate-mission tool verifies that the ID exists in the objects list. An object referenced in a trigger but not defined in objects is a hard error — the engine cannot resolve it at runtime.


Worked example — "Storm Warning"

name: "Storm Warning"
map: ukraine
layer: ukraine_clear
time: { hour: 14, minute: 0 }
wind: { heading: 270, speed: 12 }  # 12 m/s westerly; gusts added by engine
weather:
  preset: rain
sides: [nato, russia]

objects:
  - type: F22
    id: player1
    side: nato
    pos: [12400, 0, 8800]
    heading: 90
    alt: 500

  - type: SA10
    id: sam1
    side: russia
    pos: [15000, 0, 9000]
    heading: 0

triggers:
  - on: destroy(sam1)
    do: mission_success

  - on: timer(600)
    do: mission_failure

Validation

Run validate-mission <file.yaml> to check a mission file before committing it. The tool reports all errors in a single pass so contributors see the full list at once.

Exit codes: 0 = valid, 1 = validation failure, 2 = bad arguments.

validate-mission and the engine share one schema: the linter delegates to the engine's own mission parser (engine-mission), so a mission the tool passes is a mission the engine loads. The schema described in this document is that parser — the two cannot drift.

Schema source: this document. For format reference and additional asset types see docs/modding/formats.md.


Running a mission as an automated test

A mission can run headless to completion and report its outcome, so a scripted scenario doubles as an integration test:

fl-server --mission <name> --mission-report outcome.json

fl-server steps the mission in a deterministic fixed-step loop (no clients, no wall-clock, the overrun governor pinned off), writes a JSON outcome — outcome (success/failure/incomplete), plus elapsed_seconds, ticks, triggers_fired, live_entities, and spawned_objects — and exits. The tools/mission_test/mission_test.py wrapper runs this and asserts on the result; the mission_harness_ci_smoke ctest exercises it against the ci-mission-pack fixture mission.

For a run to be reproducible, keep triggers to the built-in predicates/actions and Lua behaviors to pure functions of (state, tick, dt, contacts) — no wall-clock, no unseeded RNG.