# LEVELS.md — level & recipe YAML reference
**Status:** current as of phase 02 (schema, parser, loader, validator, and the
`undercooked-levels` author CLI). Phase 11 extends
this document with the campaign-wide difficulty-curve notes; until then every
field of every content model is documented here, and
`tests/unit/test_levels_doc.py` keeps the two in lockstep.
## Purpose
This document defines the data-driven content contract for Undercooked: the
recipe book, one YAML file per level, the ASCII-grid legend conventions, and
every schema field with its type, default, and meaning. Content authors write
files against this reference and the loader validates them at startup — the
Python source never enumerates levels or recipes, and any document that fails
these rules is a load error naming its file, never a silently-defaulted
guess.
The models live in `src/undercooked/sim/recipe.py` (book, recipes, steps,
ingredients), `src/undercooked/sim/level.py` (levels, tiles, orders, hazards)
and `src/undercooked/sim/content.py` (discovery and loading). All models are
strict (`extra="forbid"`) and revalidate on assignment: an unknown key is an
error, not a typo someone lives with.
## Files on disk
```text
levels/
recipes.yaml <- the recipe book (see "recipes.yaml" below)
01_first_kitchen.yaml <- one level per file
02_double_shift.yaml
_draft.yaml <- ignored: a leading underscore opts a file out
notes.md <- ignored: only .yaml / .yml are content
```
`undercooked.sim.content.load_content(levels_dir, recipes_path)` is the whole
loader. Its discovery rules:
| Rule | Behaviour |
|---|---|
| Level discovery | every `*.yaml` / `*.yml` file under `levels_dir`, at any depth |
| Drafts | files whose name — or any ancestor directory's name — starts with `_` are ignored |
| The recipe book | `recipes.yaml` / `recipes.yml` inside `levels_dir` is never treated as a level; the book path is passed separately |
| Ordering | the campaign order is `(order, id)` — never filename order |
| `by_id` | derived from the ordered tuple; cannot disagree with it |
| Duplicate ids | a load error listing every duplicated id |
| Unknown pool recipes | a level whose `orders.pool` names a recipe the book does not define fails the load |
| Error reporting | every file-level problem is aggregated into one `ContentError` naming each offending file |
Every problem found in one run is aggregated into a single `ContentError` whose
message is one line per offending file — no prefix, because the caller adds
its own (`undercooked-levels` prefixes `undercooked-levels:`, `create_app`
prefixes `undercooked: content failed to load from
:`). It reads like:
```text
undercooked-levels: /…/levels/02_ghost.yaml: orders.pool names unknown recipes: ghost
/…/levels/03_wreck.yaml: : Value error, ragged grid: row 1 is 10 chars, want 9; grid character 'Z' has no tiles: legend entry
```
## recipes.yaml — the recipe book
```yaml
ingredients: [tomato, onion, plate] # optional catalog of the campaign's items
recipes:
soup: # key must equal the recipe's id
id: soup
name: Tomato Soup
base_score: 40
ingredients:
- {kind: tomato, form: raw}
- {kind: onion, form: raw, count: 2}
steps:
- {kind: chop, station: chop_board, seconds: 0.9}
- {kind: cook, station: stove, seconds: 5.0}
```
### RecipeBook
| Field | Type | Default | Meaning |
|---|---|---|---|
| `ingredients` | `list[ItemKind]` | `[]` | Catalog of item kinds the campaign uses. Informational in phase 02; item respawning uses it from phase 03 on. |
| `recipes` | `map[id → Recipe]` | required, non-empty | The recipes. Each key must equal the recipe's own `id`. |
Methods (deterministic, side-effect free):
- `get(recipe_id)` → the `Recipe`; `KeyError` naming all known ids otherwise.
- `ids()` → declaration order, as a tuple.
- `required_station_kinds(recipe_id)` → `frozenset[StationKind]` of every
station the steps name. The `RECIPE_STATION_MISSING` rule consumes this;
nothing hard-codes "soup needs a stove".
- `required_item_kinds(recipe_id)` → every ingredient kind plus `PLATE` when
`requires_plating` is true.
- `min_seconds_to_complete(recipe_id)` → the theoretical minimum for a perfect
cook: the sum of all step `seconds` plus one flat handling allowance
(`HANDLING_S = 0.6` s per recipe, the constant in `sim/recipe.py`).
The `TARGET_IMPLAUSIBLE` rule uses this as the greedy per-order floor of
what a round can score (see "Validation rules").
### Recipe
| Field | Type | Default | Meaning |
|---|---|---|---|
| `id` | non-empty `str` | required | Stable identifier level order pools reference. |
| `name` | non-empty `str` | required | Display name. |
| `base_score` | `int ≥ 0` | required | Points for a perfect delivery before tips/combo (phase 04's quality, streak and time bonuses scale them). |
| `ingredients` | `list[Ingredient]`, ≥ 1 | required | What the dish consumes. |
| `steps` | `list[Step]`, ≥ 1 | required | The ordered procedure. |
| `requires_plating` | `bool` | `true` | Whether a plain dish needs the plating step before serving. |
| `tags` | `list[str]` | `[]` | Free-form labels for scoring/difficulty hints. |
### Ingredient
| Field | Type | Default | Meaning |
|---|---|---|---|
| `kind` | `ItemKind` | required | Which item (enum in `sim/model.py`; YAML uses the lowercase value). |
| `form` | `ItemForm` | required | Form required at the moment of use: `raw`, `chopped`, `cooking`, `cooked`, `burnt`, `plated`, `dirty`. |
| `count` | `int ≥ 1` | `1` | How many units the recipe consumes. |
### Step
| Field | Type | Default | Meaning |
|---|---|---|---|
| `kind` | `pick` \| `chop` \| `cook` \| `wash` \| `plate` \| `serve` | required | The verb. A closed vocabulary — anything else is a load error. Trashing is an interaction, not a recipe step, so it has no verb here. |
| `seconds` | `float ≥ 0` | `0.0` | How long the action takes when performed. `pick`/`serve` are instantaneous (`0.0`). |
| `station` | `StationKind` | required | Where the verb happens (`spawn_shelf`, `chop_board`, `stove`, `prep`, `serve`, `sink`, `trash`). |
## A level file
```yaml
id: 01_first_kitchen # convention: the file's stem (see Working conventions)
name: First Kitchen
order: 1 # campaign position
difficulty: 1 # 1..10; feeds DIFFICULTY_ORDER + TRAVEL_TOO_SHORT, tuned in phase 11
time_limit: 150 # seconds of round time, > 0
target_score: 45 # win threshold
blurb: Two cooks, one pot. # optional flavour text
grid: # ASCII map, all rows equal length
- "#########"
- "#S..C...#"
- "#.......#"
- "#..V....#"
- "#########"
tiles: # every grid char maps to exactly one TileSpec
".": {kind: floor}
"#": {kind: wall}
"S": {kind: floor, station: spawn_shelf, item: tomato}
"C": {kind: floor, station: chop_board}
"V": {kind: floor, station: serve}
spawns: [[1, 1]] # player spawns as [x, y] grid coords
orders:
pool: [soup]
concurrent: 1
spawn_interval: [16, 20]
deadline: 60
hazards: [] # optional, see HazardSpec
station_limits: {plates: 2, pots: 1} # optional, default 99 / 99
```
### LevelDef
| Field | Type | Default | Meaning |
|---|---|---|---|
| `id` | non-empty `str` | required | Unique across the campaign; duplicate ids fail `load_content`. |
| `name` | non-empty `str` | required | Display name. |
| `order` | `int ≥ 1` | required | Campaign position. Levels are sorted by `(order, id)`. |
| `difficulty` | `int` 1–10 | `1` | Author-declared difficulty. Feeds `DIFFICULTY_ORDER` (the campaign may not declare an easier level after a harder one) and switches on `TRAVEL_TOO_SHORT` from 3 upward; phase 11 tunes the curve against it. |
| `time_limit` | `float > 0` | required | Round length in seconds. |
| `target_score` | `int ≥ 1` | required | Score needed to win the round. |
| `grid` | `list[str]`, ≥ 1 row | required | The ASCII map. Every row the same length, at most 64×64 (`MAX_GRID`), every character present in `tiles`. |
| `tiles` | `map[char → TileSpec]`, ≥ 1 | required | The legend. Every legend character must be used by the grid — unused entries are typos. |
| `spawns` | `list[[x, y]]`, ≥ 1 | required | Player spawn coordinates, each inside the grid. |
| `orders` | `OrdersSpec` | required | The order engine configuration for this level. |
| `hazards` | `list[HazardSpec]` | `[]` | Placed and periodic hazards. |
| `station_limits` | `StationLimits` | `99 / 99` | Concurrent capacity of shared station resources. |
| `blurb` | `str` | `""` | Flavour text for the level card. |
| `escalation_notes` | `str` | `""` | Free-form note to the phase 11 difficulty-curve pass. |
Schema-level rules (enforced right here, at load):
- **rectangular grid** — differing row lengths are a load error naming the row;
- **legend completeness both ways** — no grid character without a legend
entry, no legend character the grid never uses;
- **spawn inside the grid**, at least one spawn;
- **exactly one serve legend character** in the tiles map — the validator
rejects levels where the win condition cannot be reached by definition
(the validator's flood-fill rules below go further and reject pockets no
cook can walk into);
- numeric sanity on every counter, interval, and limit listed above.
### TileSpec
| Field | Type | Default | Meaning |
|---|---|---|---|
| `kind` | `TileKind` (`floor` \| `counter` \| `wall` \| `void`) | required | Walkability class; only `floor` is walkable (`WALKABLE` in `sim/model.py`). |
| `station` | `StationKind` or `null` | `null` | The station installed on this tile, if any. |
| `item` | `ItemKind` or `null` | `null` | What this tile restocks — legal **only** on a `spawn_shelf` station tile, and **mandatory** there. |
| `id_prefix` | non-empty `str` or `null` | `null` | Replaces the station-kind stem in generated ids (see "Station ids"). Allowed only on station tiles. |
Stations sit on tiles of any `kind`; the schema allows it so hazards and
decorations can mount stations, but the `STATION_REACHABLE` rule demands a
walkable (`floor`) tile for every station players must touch.
### OrdersSpec
| Field | Type | Default | Meaning |
|---|---|---|---|
| `pool` | `list[str]`, ≥ 1 | required | Recipe ids this level draws orders from. Every entry must exist in the recipe book (`load_content` checks; the validator re-checks hand-built levels). |
| `concurrent` | `int ≥ 1` | required | Maximum simultaneously active orders. |
| `spawn_interval` | `[min, max]` seconds, both > 0 | required | Uniform range the round RNG draws from. `min ≤ max` is validated — reversed intervals are the classic misconfiguration. |
| `deadline` | `float > 0` | required | Seconds an order may live before it burns. |
| `penalty` | `int ≥ 0` | `10` | Points lost when an order burns out. |
### HazardSpec
One shape for placed and periodic hazards. Placement rules by `type`:
| `type` | Placement | Meaning |
|---|---|---|
| `fire` | `station` **or** `x`/`y` (exactly one) | Ignites that station or that cell. |
| `spill` | `station` **or** `x`/`y` (exactly one) | Grease puddle at that location. |
| `moving_obstacle` | neither; `path` + `period_s` instead | Travels the path back and forth on a loop. |
| `rush` | nowhere (level-wide) | A dining-rush surge; needs a positive `intensity`. |
| Field | Type | Default | Meaning |
|---|---|---|---|
| `type` | `HazardKind` (`fire` \| `spill` \| `moving_obstacle` \| `rush`) | required | Which hazard. |
| `station` | `str` or `null` | `null` | Station id to attach to (e.g. `stove_2`). Mutually exclusive with `x`/`y`. |
| `x` | `int ≥ 0` or `null` | `null` | Cell x. Must be given together with `y`. |
| `y` | `int ≥ 0` or `null` | `null` | Cell y. Must be given together with `x`. |
| `chance_per_s` | `float ≥ 0` or `null` | `null` | Periodic re-arm probability per second; `null` = placed once at round start. |
| `respawn_s` | `float > 0` or `null` | `null` | Delay before a cleared hazard can re-arm. |
| `path` | `list[[x, y]]`, ≥ 2 points, or `null` | `null` | Route for `moving_obstacle` only. |
| `period_s` | `float > 0` or `null` | `null` | Loop period for `moving_obstacle`; required when it has a `path`. |
| `intensity` | `float` (must be > 0 when `type: rush`) | `1.0` | Magnitude (`rush` surge strength; severity multiplier elsewhere). |
### StationLimits
| Field | Type | Default | Meaning |
|---|---|---|---|
| `plates` | `int ≥ 0` | `99` | Clean plates available at once. `0` loads and is reported by `STATION_LIMIT_ZERO` — the rule, not a schema error, so the report names it. |
| `pots` | `int ≥ 0` | `99` | Pots the stoves share. |
## Station ids and the built `Level`
`build_grid(spec)` walks the grid in reading order (row by row, left to
right) and assigns each station tile an id: `{id_prefix or station-kind
value}_{n}`, where `n` counts **per station kind** across the whole level.
Two legend characters of the same kind therefore share one counter — a level
with `T` (stove) and `t` (stove, `id_prefix: burner`) produces `stove_0` and
`burner_1`. The same walk yields the `Station` list and `build_grid`'s
`Grid.station_at` (cell index → station id), and `Grid.from_rows` re-checks
geometry one last time so the grid cannot disagree with the spec.
`Level.build(spec)` pairs the validated `LevelDef` with what the parser
produces — `grid` (the `sim/grid.py` `Grid`), `stations`, `spawns` (as
`(x, y)` tuples) and `station_items` (spawn-shelf id → restocked `ItemKind`)
— and delegates `id`, `order`, `difficulty`, `time_limit`, `target_score`,
`orders`, `hazards`, `station_limits`, `blurb` and `escalation_notes` to the
spec, so simulation code reads one object. The `ContentBundle` returned by
`load_content` holds the `recipe_book`, the campaign-ordered `levels`, and
the derived `by_id` map.
## Validation rules
`sim/validation.py` proves a built `Level` is playable before anyone joins it —
the PLAN §8 content gate. `validate_level(level, book, prev=None)` runs the
per-level rules (`prev` is the campaign predecessor and enables the escalation
check); `validate_levels(levels, book)` adds the bundle-wide rules over an
`(order, id)`-ordered list and `validate_bundle(bundle)` runs that over a
loaded bundle. Each hit is a `LevelViolation` naming a stable **rule id** with
a human message carrying coordinates and a machine-readable `detail` mapping.
`ValidationReport.render()` prints a deterministic, ANSI-free,
level-grouped report, and **warnings never fail a level** — `report.ok` reads
violations only.
| Rule | Fires when |
|---|---|
| `GRID_SHAPE` | the built `Grid` disagrees with the spec's ASCII row count or width (hand-built levels; `LevelDef` validation already rejects ragged grids) |
| `SPAWN_ON_FLOOR` | a spawn is not on a walkable floor tile |
| `SPAWN_COUNT` | the level has no player spawns |
| `STATION_REACHABLE` | a station cell cannot be walked to from any spawn |
| `SERVE_REACHABLE` | the same for the serve window specifically — no reachable window means no round can ever be won |
| `SPAWNS_MUTUALLY_REACHABLE` | a spawn cannot be walked to from the first spawn |
| `RECIPE_STATION_MISSING` | a pool recipe needs a station kind the level does not have within the reachable area |
| `RECIPE_INGREDIENT_UNAVAILABLE` | a pool recipe needs a `raw` ingredient no reachable spawn shelf produces (spawn shelves are currently the only item source the content model expresses) |
| `ORDER_POOL_UNKNOWN_RECIPE` | `orders.pool` names a recipe the recipe book does not define |
| `TARGET_IMPLAUSIBLE` | `target_score` exceeds the plausibility bound below — above 45% of the bound it is a **warning**, above 60% an error |
| `TIME_LIMIT_INVALID` | `time_limit` is under 30 s |
| `HAZARD_REF_UNKNOWN` | a hazard references a station id that does not exist |
| `HAZARD_ON_UNREACHABLE_TILE` | a hazard tile or `moving_obstacle` path point is off-grid, not walkable, or cut off from the cooks |
| `STATION_LIMIT_ZERO` | `station_limits.plates` or `.pots` is below 1 |
| `DUPLICATE_ID` | two levels in the bundle share an `id` (bundle rule) |
| `DIFFICULTY_ORDER` | a level raises fewer than two escalation axes over its predecessor, or the campaign's declared `difficulty` drops between consecutive levels (bundle rule) |
| `TRAVEL_TOO_SHORT` | from difficulty 3 up, the mean spawn→serve path is under 3 tiles — sprint-to-serve layouts are not campaigns |
**Target plausibility** (`theoretical_max_score`) is the greedy bound
`floor(time_limit / mean_complete) × mean_order_value` over the level's order
pool, where `mean_complete` is the recipe book's `min_seconds_to_complete()`
average plus a travel bound of `2 × mean spawn→serve tiles ÷ 3.5 tiles/s` —
one walk to the window and one back at max cook speed (PLAN §5). The bound is
deliberately generous: it rejects targets a perfect team could never hit, not
targets a sloppy one misses. Levels with an invalid time limit or no known
pool recipes return no bound — those conditions have their own rules.
**Escalation axes** (`escalation_axes`, every axis oriented *higher = harder*):
| Axis | Measure |
|---|---|
| `recipe_complexity` | number of distinct recipes in the order pool |
| `order_pressure` | `orders.concurrent` ÷ mean spawn interval |
| `time_budget` | `target_score` ÷ `time_limit` |
| `station_scarcity` | 1 ÷ station count |
| `layout_hostility` | blocking tiles ÷ total tiles |
| `hazard_density` | number of hazards |
| `travel_distance` | mean BFS tiles from a spawn to the nearest serve cell |
Reachability is a BFS flood fill over walkable tiles from every spawn.
`moving_obstacle` tiles are deliberately *not* subtracted from the reachable
set — movers move, so only their path points must be valid floor. When the
serve window is unreachable the travel metric falls back to Manhattan
distance so the numbers stay defined; the reachability rules report the break
itself. `undercooked-levels validate` prints this report for the levels
directory and exits `1` on any violation — see **Authoring workflow** below. The
same report stops the game server: `create_app` runs that exact pair at startup,
so `uv run undercooked` prints it, refuses to build an app around it and exits
`2` rather than open a lobby nobody can cook in.
## Working conventions
- `id` should equal the file's stem; the loader keys everything by `id`, and
the bundle rule `DUPLICATE_ID` refuses a bundle that reuses one.
- Two characters may share a `TileSpec` meaning only by writing it twice;
characters are single characters — a multi-character legend key can never
match a grid cell, so the load fails with `legend character 'VV' is never
used by the grid`, while a grid character with no entry at all fails with
`grid character 'Z' has no tiles: legend entry`.
- Spawn shelves are the only item source at schema level; deeper restocking
semantics belong to phase 03's simulation work.
- Everything strict: when a field is added to a model, `docs/LEVELS.md` must
gain a row in the same change — `tests/unit/test_levels_doc.py` fails if
the doc table and `model_fields` ever disagree.
## Authoring workflow
`undercooked-levels` is the author's half of this contract, and a thin shell
over the code above: `validate` is exactly `load_content` + `validate_bundle`
(the same pair `create_app` runs at startup), so green here means a server that
boots there. It ships with the project (`[project.scripts]` in
`pyproject.toml`) and never enumerates content itself — it prints whatever the
directory it was pointed at contains. `tests/unit/test_content_not_hardcoded.py`
holds the CLI, the loader and the content API to that rule.
```bash
uv run undercooked-levels new 03_riverboat # 1. scaffold a level, validate that file
uv run undercooked-levels list # 2. the campaign, in campaign order
uv run undercooked-levels show 03_riverboat --grid # 3. read the resolved level + overlay
uv run undercooked-levels validate # 4. the gate .agents/validate.sh runs
uv run undercooked-levels axes # 5. the escalation table phase 11 tunes
```
Every subcommand takes `--dir PATH` (levels) and `--recipes PATH` (the book),
which beat `UNDERCOOKED_LEVELS_DIR` / `UNDERCOOKED_RECIPES_PATH`, which beat the
defaults `levels` and `/recipes.yaml`. Path resolution is settings-driven,
never cwd-driven: a *relative* path with nothing under the current directory is
anchored to this checkout, so the commands above work from `/tmp` exactly as
they do from the repo root.
1. **`new ID [--order N]`** writes `/ID.yaml` — a valid, minimal kitchen
*generated from the recipe book*: one spawn shelf per raw ingredient of the
book's first dish, one station per kind that dish's steps name, and two
spawns on the floor row far enough from the window to clear
`TRAVEL_TOO_SHORT`. `--order` defaults to one past the highest in the
directory; `difficulty` starts one above the campaign's hardest. It refuses
to overwrite an existing level, and prints the single-level validation
report of what it wrote.
2. **Edit** the grid, the tunables and the prose. `id` should equal the file's
stem. Fill `escalation_notes` with the axes you moved: the campaign rule
(`DIFFICULTY_ORDER`) wants at least two axes up versus the level that
precedes yours by `order`, and a `difficulty` that never drops.
3. **`list` and `show ID [--grid]`** read the level back the way the server sees
it — campaign order and resolved geometry, not the text you typed. `show`
prints the tunables, the station and hazard counts, how much of the floor is
reachable, and each pool dish resolved against the book (or
`MISSING from the recipe book`).
4. **`validate`** prints one status line per level and then
`ValidationReport.render()`. Warnings print and stay green — a warning is a
tuning question, a violation is a broken level:
```text
99_broken_room 4 error(s) 11x6, 4 stations
4 error(s), 0 warning(s)
level 99_broken_room
ERROR RECIPE_STATION_MISSING: recipe 'soup' needs a reachable prep station; the level has none
ERROR RECIPE_STATION_MISSING: recipe 'soup' needs a reachable stove station; the level has none
ERROR SERVE_REACHABLE: serve station serve_0 at (8,1) is not reachable from any spawn
ERROR TARGET_IMPLAUSIBLE: target_score 999 is above 60% of the theoretical maximum 160; no one could ever reach it
```
5. **`axes`** prints the seven escalation axes per level in campaign order
(higher = harder), which is how you check a level really got harder instead
of only *feeling* harder:
```text
LEVEL ORDER recipe_complexity order_pressure time_budget station_scarcity layout_hostility hazard_density travel_distance
01_first_kitchen 1 1.0000 0.0556 0.3000 0.1429 0.3519 0.0000 7.5000
02_double_shift 2 2.0000 0.1429 0.6154 0.1250 0.4786 0.0000 5.5000
```
### The overlay debugging trick
`STATION_REACHABLE`, `SERVE_REACHABLE` and `HAZARD_ON_UNREACHABLE_TILE` all
come down to one question: which walkable floor can the cooks actually get to?
`show --grid` re-draws the authored map as terrain so you see the answer
instead of deducing it from a BFS you cannot see:
```bash
uv run undercooked-levels show 99_broken_room --grid --dir tests/fixtures/levels/_bad
```
```text
map (as authored) <- the file, exactly as written
###########
#T..C#..V.#
#....#....#
#.#########
#S........#
###########
reachability . reachable ? walkable but cut off # blocking
###########
#....#????#
#....#????#
#.#########
#.........#
###########
18/26 walkable tiles are reachable from the spawns
```
The `?` band is the sealed servery: walkable floor, and the wall at column 6
cuts it off from the spawn on row 4. Fix the wall, not the target score. Note
that a moving obstacle's `path` tiles stay `.` even when a mover currently
blocks them — movers move, so the validator only insists the path itself is
valid floor.
### Scripting it
`validate --json` is the machine-readable form of the same report — stable key
names, sorted hits, so a diff of two runs is only a diff of real changes:
```bash
uv run undercooked-levels validate --json | python -m json.tool
```
```json
{
"ok": false,
"counts": {"errors": 4, "warnings": 0},
"errors": [
{
"level": "99_broken_room",
"rule": "SERVE_REACHABLE",
"message": "serve station serve_0 at (8,1) is not reachable from any spawn",
"detail": {"station_id": "serve_0", "x": 8, "y": 1}
}
]
}
```
Exit codes are the whole scripting surface: `0` everything is fine, `1` the
content loaded and breaks at least one rule, `2` the tool could not do its job
(content would not load, an unknown level id, a file `new` refuses to overwrite,
or an argparse usage error). `.agents/validate.sh` runs `validate` unconditionally,
so a campaign that cannot validate cannot commit even when no test file changed.
### Which rule did I just hit?
The full semantics are in the **Validation rules** table above; this is the
"what do I type next" view of the same ids.
| Rule | Usually means |
|---|---|
| `GRID_SHAPE` | the row count/width you wrote and the built grid disagree — check for a ragged row or a stray character |
| `SPAWN_ON_FLOOR` | a `spawns:` coordinate lands on wall/counter — move it onto a `.` |
| `SPAWN_COUNT` | `spawns:` is empty — a kitchen nobody stands in |
| `STATION_REACHABLE` | a fixture sits behind a wall — `show --grid` and open a path |
| `SERVE_REACHABLE` | the window is unreachable, so the round cannot be won — same fix, and the level is unwinnable until then |
| `SPAWNS_MUTUALLY_REACHABLE` | the cooks start on islands — connect the spawn areas |
| `RECIPE_STATION_MISSING` | a pool dish needs a station the level lacks (or cannot reach) — add the fixture or trim the pool |
| `RECIPE_INGREDIENT_UNAVAILABLE` | a pool dish eats an item no reachable spawn shelf produces — add a shelf for it |
| `ORDER_POOL_UNKNOWN_RECIPE` | `orders.pool` names a dish `recipes.yaml` does not define — check the spelling |
| `TARGET_IMPLAUSIBLE` | `target_score` is out of reach of a perfect team — lower the target, lengthen `time_limit`, or raise the dish values |
| `TIME_LIMIT_INVALID` | `time_limit` under 30 s — a round that is not a round |
| `HAZARD_REF_UNKNOWN` | a hazard's `station:` id is not in the level — re-read the station ids the loader built |
| `HAZARD_ON_UNREACHABLE_TILE` | a hazard sits in a wall or a sealed pocket — move it where the cooks are |
| `STATION_LIMIT_ZERO` | `station_limits.plates`/`.pots` is 0, so no dish can ever be plated |
| `DUPLICATE_ID` | two files declare the same `id` — one of them is a copy-paste |
| `DIFFICULTY_ORDER` | the level moves fewer than two escalation axes up, or `difficulty` dropped — compare with `axes` |
| `TRAVEL_TOO_SHORT` | from difficulty 3, spawn-to-serve averages under 3 tiles — spread the kitchen out |