Files
dungeoncraft/docs/MISSION_SCRIPTING.md
T

1668 lines
44 KiB
Markdown

# Mission scripting with Rhai
This guide describes the scripting language for missions and the level/campaign editor. The language is **Rhai**, because it runs directly in Rust, works in WASM, has a simple syntax and is easy to sandbox.
## Status
The document is a design draft plus a first implementation. Most of the API below is **planned**; sections and examples that use planned parts are marked with **Planned**. What exists today:
**Runtime** (`src/script.rs`, applied in `start_custom_script()` in `src/main.rs`)
- Only missions of a custom campaign (`.dccamp`, `MissionDoc.script`) run scripts. The script language must be `rhai` (or empty); any other value shows `msg.script_language`.
- One hook: `on_start(m)`, called once when the mission starts. A script without `fn on_start(` is allowed and does nothing.
- The script records actions while it runs; the game applies them right after the hook returns.
- The engine stops a script after 200,000 operations (endless loops become a script error).
- Rhai errors (syntax, unknown function, wrong argument types, too many operations) are shown in game as `msg.script_error`.
**Actions on `m`**
| Call | Effect |
|---|---|
| `m.log(text)` | shows `text` as a message (`msg.script`) |
| `m.objective(text)` | shows `Objective: text` as a message (`msg.script_objective`); the last call wins |
| `m.spawn_at(side, kind, x, y, count, level)` | spawns `count` creatures of `kind` at the world position `x`, `y` |
| `m.spawn_count_in_zone(zone, kind, count, side, level)` | spawns `count` creatures of `kind` spread over the named zone |
| `m.spawn_wave(count)` | turns the mission's hero waves on, starts the next wave right away and makes sure at least `count` more waves may come |
Argument details:
- `side`: an integer `0` to `4`, or a name: `"PLAYER"` (0), `"HEROES"` (1, also `"HERO"`), `"MOSSGRIM"` (2), `"NIGHTWELL"` (3), `"RIVAL3"` (4, also `"RIVAL"`). Names are case-insensitive.
- `kind`: a creature name from the list in [Creatures](#creatures), case-insensitive.
- `x`, `y`: **floating point** world coordinates in tiles (`21.5` is the centre of tile 21). Integer literals do not match: `m.spawn_at("HEROES", "Knight", 21, 21, 3, 2)` fails with "function not found"; write `21.0` or `21.5`. Positions outside the map are skipped silently.
- `count`, `level`, `side` (as number): integers. `count` and `level` are clamped to `1..10`; `spawn_wave` clamps `count` to `1..20`.
- `zone`: the `name` of a zone in the mission's `zones` list (see [Zones in the mission definition](#zones-in-the-mission-definition)).
**Validation** (`validate_script_source()` in `src/main.rs`)
When a script is entered in the campaign editor (*Edit script*, up to 600 characters), it is run once with `on_start` and rejected if:
- Rhai reports an error (syntax error, unknown function, wrong argument types, too many operations),
- a spawn uses an unknown creature name,
- `spawn_count_in_zone` names a zone that the mission does not define.
Not checked yet: unknown side names (such a spawn is dropped silently), unknown side numbers (reported only in game), coordinates outside the map (skipped silently). Scripts loaded from a `.dccamp` file are not validated; the same errors then show up in game as `msg.script_error`.
**Zones**: missions can store zones as data (`zones` in the `.dccamp` file, shapes `circle`, `rect`, `line`, `tiles`). They are used by `spawn_count_in_zone` and drawn by the *Debug trigger zones* setting. There is no graphical zone editor yet and no zone trigger hooks.
**Not implemented yet**: all other hooks, readable properties on `m`, flags/variables/counters, hints/warnings/effects, position helpers (`m.at`, ...), zone functions in the script, destruction actions, random functions, limit changes, goal and branch objects, and script files outside the campaign file.
**Rhai notes**
- `spawn` is a reserved word in Rhai. Even `m.spawn(...)` is a parse error, so the API uses `spawn_at`.
- Strings use double quotes. `'x'` is a single character in Rhai, and `'abc'` is a syntax error.
- Functions cannot see variables or constants of the main script. A top-level `const HEROES = 1;` must be written as `global::HEROES` inside a function; plain `HEROES` fails with "variable not found". The side names as strings (`"HEROES"`) avoid this.
## 1. What mission scripting is for
Mission scripting allows missions that are more than fixed data:
- Timed events: after 300 seconds reinforcements attack.
- Reactions: when the player loots a vault, a paladin appears.
- Phases: first waves, then a fortress, then a boss.
- Controlled randomness: a patrolling group takes one of two paths.
- Dynamic briefings and objectives.
- Rivals, caves, shards, mana springs and gates can react to the player's progress.
- Campaign missions can later be edited with their own story, goals and logic.
Important: scripts change missions, they do not replace the whole game. They therefore run in a sandbox.
## 2. Language: Rhai
Mission scripts are written in Rhai.
Example:
```rhai
const HERO = 1;
const MOSSGRIM = 2;
const NIGHTWELL = 3;
fn on_start(m) {
m.log("Crookfinger has a plan.");
m.objective("Find the shard before the guards raise the alarm.");
}
```
Rhai is deliberately simple:
- Variables with `let`.
- Constants with `const`.
- Functions with `fn`.
- `if`, `for`, `while`, booleans, numbers and strings (in double quotes).
- Arrays with `[...]`.
- Maps with `#{...}`.
- No files, no network access, no direct access to Rust memory.
Constants declared at the top are only visible inside functions as `global::NAME`:
```rhai
const HEROES = 1;
fn on_start(m) {
m.spawn_at(global::HEROES, "Knight", 21.5, 21.5, 2, 1); // works
// m.spawn_at(HEROES, ...) would fail: functions do not see HEROES
}
```
## 3. Where a mission script lives
A mission consists of data and optionally a script. Today the script is stored inside the mission in the campaign file (`.dccamp`) as an object with `language` and `source`:
```json
{
"key": "custom01",
"title": "The first trap",
"story": "The guards believe the old shaft is safe. They are wrong.",
"size": [96, 80],
"seed": 123456,
"gold": 2500,
"goals": ["Shards", "Survive(3)"],
"script": {
"language": "rhai",
"source": "fn on_start(m) { m.objective(\"Find the shard.\"); }"
}
}
```
In the campaign editor the script is edited with *Edit script* in a single-line text prompt. **Planned**: a larger text box and scripts in separate files such as `missions/custom01.rhai` (`"script": "missions/custom01.rhai"`).
The campaign file stores the whole structure:
```json
{
"format": "dungeoncraft.campaign",
"version": 1,
"name": "My campaign",
"author": "",
"missions": [
{
"key": "custom01",
"title": "The first trap",
"story": "Short text for the briefing.",
"goals": ["Shards"],
"script": { "language": "rhai", "source": "fn on_start(m) { m.objective(\"Find the shard.\"); }" }
},
{
"key": "custom02",
"title": "The second wave",
"story": "After the shard the Order comes back.",
"goals": ["Survive(4)"],
"script": { "language": "rhai", "source": "fn on_start(m) { m.spawn_wave(2); }" }
}
]
}
```
See `docs/CAMPAIGN_EDITOR.md` for the full campaign format.
## 4. The mission object `m`
Every hook receives a mission object `m`. It provides the mission, the game state and the allowed actions. Today `m` only offers the actions listed in [Status](#status).
### Readable properties
**Planned** - none of these properties exist yet; reading them is a script error.
| Field | Meaning |
|---|---|
| `m.time` | elapsed mission time in seconds |
| `m.map_w` | map width in tiles |
| `m.map_h` | map height in tiles |
| `m.gold` | gold of the currently active master |
| `m.mana` | mana of the currently active master |
| `m.loot` | vault gold looted so far |
| `m.shards_taken` | soul core shards taken |
| `m.springs` | mana springs claimed |
| `m.freed` | chained creatures freed |
| `m.wave` | current or last hero wave number |
| `m.time_limit` | time limit in seconds, `0` means no limit |
| `m.wave_limit` | wave limit, `0` means endless |
| `m.gate_hp` | hit points of the Order's gate |
| `m.state` | `0` running, `1` victory, `2` defeat |
### Sides
Implemented for `spawn_at` and `spawn_count_in_zone`:
| Name | Number | Meaning |
|---|---|---|
| `"PLAYER"` | `0` | red master, normally the player |
| `"HEROES"` (or `"HERO"`) | `1` | the Order of knights |
| `"MOSSGRIM"` | `2` | green rival Mossgrim |
| `"NIGHTWELL"` | `3` | blue rival Lady Nightwell |
| `"RIVAL3"` (or `"RIVAL"`) | `4` | third rival, yellow |
The names are strings, not built-in constants. Either pass the string, or declare your own constants and use them as `global::NAME` inside functions:
```rhai
const HEROES = 1;
const MOSSGRIM = 2;
const NIGHTWELL = 3;
```
### Creatures
Creature names as strings, matching the internal names (`parse_spawn_kind()` in `src/script.rs`, case-insensitive):
```rhai
"Worker"
"Kobold"
"Spider"
"Mage"
"Ogre"
"Knight"
"Archer"
"Priest"
"Dwarf"
"Paladin"
"Jailer"
"Priestess"
"Chainbreaker"
"Bat"
"Gargoyle"
"Owl"
"Wyrm"
"Cogling"
"Revenant"
"Giant"
"Fairy"
"BattleMage"
```
Any other name is rejected by the editor validation.
Example:
```rhai
m.spawn_at("HEROES", "Knight", 69.5, 38.5, 1, 2);
```
### Goals
**Planned** - goal objects and `on_goal` do not exist yet.
Mission goals can be handled as goal objects:
```rhai
goal.name
goal.count
goal.done
```
Names (the same names as in the mission's `goals` list; `Loot`, `Survive`, `Springs` and `Freed` take a number there, e.g. `Survive(3)`):
```rhai
Shards
Heart
KillAll
Loot
Towers
Survive
Rivals
Springs
Freed
```
Example:
```rhai
if goal.name == "Shards" && goal.done {
m.objective("Second stage: the fortress.");
}
```
## 5. Allowed actions
The allowed functions are deliberately few and safe.
### Text and UI
Implemented: `m.log` and `m.objective`. **Planned**: `m.hint` and `m.warn`.
```rhai
m.log("Message in the log.");
m.hint("Hint for the player."); // planned
m.warn("Warning with alarm sound."); // planned
m.objective("Current objective in the interface.");
```
`m.objective` currently shows the text as a message (`Objective: ...`), not in a separate objective panel.
### Effects
**Planned.**
```rhai
m.play("alarm");
m.flash("danger");
m.flash("treasure");
```
Planned effect names:
```rhai
alarm
danger
treasure
claim
level_up
heal
```
### Variables, flags and counters
**Planned.**
```rhai
m.set("phase", 1);
m.get("phase");
m.has("phase");
m.set_flag("guards_alerted");
m.has_flag("guards_alerted");
m.clear_flag("guards_alerted");
m.bump("vaults_opened");
m.counter("vaults_opened");
```
Rule: every action that should happen only once is guarded by a flag or a counter.
### Positions
Implemented: `m.spawn_at` takes plain world coordinates in tiles (`x`, `y` as floats; `x + 0.5` is the centre of tile `x`). Zones use tile coordinates too.
**Planned**: position helpers. In the editor, coordinates are best given as fractions of the map:
```rhai
m.at(0.70, 0.50)
```
This means 70 percent of the map width and 50 percent of the map height. The game converts this to tile coordinates.
More planned position helpers:
```rhai
m.map_center()
m.fort_pos(0)
m.cave_pos(0)
m.gate_pos()
m.core_pos(0)
```
To use these positions for spawning, a planned `spawn_at` variant takes a position instead of `x`, `y`:
```rhai
m.spawn_at("HEROES", "Knight", m.at(0.72, 0.48), 1, 2); // planned
```
The examples below use plain tile coordinates where possible and assume a 96x80 map (`m.at(0.70, 0.50)` is about `67.5, 40.5` there).
### Trigger zones, marked tiles and lines
Location triggers are named places, areas or paths in the mission. The script can react when a creature reaches such a place or crosses a line, spawn creatures in a zone, or cause destruction in the zone.
Implemented: zones stored in the mission data (`zones` in the `.dccamp` file) and `m.spawn_count_in_zone`. Everything else in this section is **planned**: defining zones in the script, drawing them in the editor, zone triggers and destruction actions.
#### Defining zones in the script
**Planned.**
```rhai
fn on_start(m) {
m.add_zone("north_gate", m.at(0.70, 0.45), 2.0);
m.add_zone("south_gate", m.at(0.70, 0.55), 2.0);
m.add_zone("vault_approach", m.at(0.82, 0.50), 3.0);
}
```
Areas made of marked tiles:
```rhai
fn on_start(m) {
m.add_tiles_zone("vault_path", [
[34, 40],
[34, 41],
[35, 40]
]);
}
```
Rectangles and lines:
```rhai
fn on_start(m) {
m.add_rect_zone("guard_room", m.at(0.70, 0.40), m.at(0.80, 0.60));
m.add_line("bridge_path", [m.at(0.60, 0.50), m.at(0.72, 0.50)], 1.0);
}
```
#### Drawing zones in the editor
**Planned** - the campaign editor cannot draw zones yet; zones are written into the `.dccamp` file by hand.
The level/campaign editor can place zones graphically:
- Circle: drag centre and radius.
- Rectangle: drag two corners.
- Polygon: click points.
- Marked tiles: paint single tiles or an area.
- Line: draw a bridge, path, patrol route or border line.
- Optional filter: side, creature type, once only, player creatures only, enemy creatures only.
Zones placed graphically are stored in the mission data and made available to the script automatically. The script does not have to define them by hand, but it can always add more zones.
Example script call:
```rhai
fn on_start(m) {
m.load_editor_zones();
}
```
After `m.load_editor_zones()` all zones drawn in the editor are available in the script. Additional triggers can be added afterwards:
```rhai
fn on_start(m) {
m.load_editor_zones();
m.add_zone("backup_path", m.at(0.58, 0.50), 2.0);
}
```
The editor can also show the generated part of the script:
```rhai
// --- editor-generated zones: normally not edited manually ---
fn editor_zones(m) {
m.add_zone("north_gate", m.at(0.70, 0.50), 3.0);
m.add_tiles_zone("vault_path", [[34, 40], [34, 41], [35, 40]]);
}
```
The mission script then simply calls `editor_zones(m)`.
#### Zones in the mission definition
Implemented. Zones can be stored directly in the mission definition. All coordinates are **absolute tile coordinates** (not map fractions):
```json
"zones": [
{
"name": "north_gate",
"shape": "circle",
"at": [67, 36],
"radius": 2.0
},
{
"name": "vault_approach",
"shape": "circle",
"at": [78, 40],
"radius": 3.0
},
{
"name": "guard_room",
"shape": "rect",
"from": [67, 32],
"to": [77, 48]
},
{
"name": "vault_path",
"shape": "tiles",
"tiles": [[34, 40], [34, 41], [35, 40]]
},
{
"name": "bridge_path",
"shape": "line",
"from": [58, 40],
"to": [69, 40],
"width": 1.0
}
]
```
| Shape | Fields | Spawn positions of `spawn_count_in_zone` |
|---|---|---|
| `circle` (default) | `at`, `radius` (default `2.0`); without `at` the zone sits on the player's core | evenly on a ring inside the radius, or the centre for one creature |
| `rect` (also `rectangle`, `square`) | `from`, `to` (corner tiles) | a grid across the rectangle |
| `line` (also `path`) | `from`, `to`, `width` (only drawn) | evenly along the line |
| `tiles` | `tiles` (list of `[x, y]`) | the first `count` tiles of the list |
Positions outside the map are dropped. If none is left, the game reports `no usable spawn position in zone`. **Planned**: a `polygon` shape.
#### Zone actions in the script
Once a zone is defined, the script can trigger actions in it. Implemented: `m.spawn_count_in_zone`. **Planned**: `m.spawn_in_zone`, the `destroy_*` functions and `m.warn`.
```rhai
m.spawn_in_zone("north_gate", "Knight", 3, "HEROES"); // planned
m.spawn_count_in_zone("north_gate", "Knight", 3, "HEROES", 2);
m.spawn_count_in_zone("north_gate", "Archer", 3, "HEROES", 1);
m.destroy_walls_in_zone("north_gate"); // planned
m.destroy_walls_at(m.at(0.72, 0.50), 2.0); // planned
m.destroy_doors_in_zone("vault_path"); // planned
m.destroy_towers_in_zone("north_gate"); // planned
m.log("The zone is open.");
m.warn("The knights are coming through the gap!"); // planned
```
Example (**planned**: zone triggers, flags, `m.warn`, `destroy_*`, `m.fort_pos`): a creature reaches a graphically placed zone, the wall falls, and enemies appear behind it.
```rhai
const HEROES = 1;
const PLAYER = 0;
fn on_start(m) {
m.load_editor_zones();
m.log("A marked zone has been placed in the editor.");
}
fn on_unit_reaches(m, unit, zone) {
if zone != "north_gate" {
return;
}
if unit.side != global::PLAYER {
return;
}
if m.has_flag("north_gate_opened") {
return;
}
m.set_flag("north_gate_opened");
m.log("A creature has reached the north gate.");
m.warn("Knights are waiting behind the wall!");
m.destroy_walls_in_zone("north_gate");
m.spawn_count_in_zone("north_gate", "Knight", 3, global::HEROES, 2);
m.spawn_at(global::HEROES, "Archer", m.fort_pos(0), 1, 3);
}
```
These functions must not edit arbitrary tiles. They only work through the mission API and are deterministic.
### Spawning creatures and waves
Implemented (`spawn` is reserved in Rhai, so the name is `spawn_at`):
```rhai
m.spawn_at(side, kind, x, y, count, level);
m.spawn_count_in_zone(zone, kind, count, side, level);
```
Example:
```rhai
m.spawn_at("HEROES", "Knight", 21.5, 21.5, 3, 2);
m.spawn_count_in_zone("north_gate", "Knight", 3, "HEROES", 2);
```
```rhai
m.spawn_at("HEROES", "Knight", 69.5, 38.5, 1, 2);
```
Groups through several calls:
```rhai
m.spawn_at("HEROES", "Knight", 69.5, 36.5, 1, 2);
m.spawn_at("HEROES", "Archer", 71.5, 44.5, 1, 2);
```
Creatures of side `1` are spawned as heroes; all other sides spawn creatures for that master.
Waves:
Implemented: `m.spawn_wave(count)`. It turns the mission's waves on, starts the next wave right away, and, if the mission has a wave limit, raises the limit so that at least `count` more waves can come (a limit of `0` stays endless):
```rhai
m.spawn_wave(6);
```
**Planned**:
```rhai
m.set_wave_limit(8);
m.set_first_wave(180);
```
Changing goals and limits (**planned**):
```rhai
m.set_time_limit(1200);
m.add_time_limit(300);
m.set_wave_limit(10);
```
### Randomness
**Planned.** Only use the allowed random functions:
```rhai
m.rand_int(1, 10)
m.rand_choice(["north", "south"])
```
Example:
```rhai
let path = m.rand_choice(["north", "south"]);
m.set("patrol_path", path);
```
## 6. Event hooks
A mission script can define several hooks. Not every hook has to be present.
Implemented: only `on_start(m)`. All other hooks are **planned**; defining them today is allowed but they are never called.
| Hook | When | Status |
|---|---|---|
| `on_start(m)` | mission start | implemented |
| `on_time(m)` | regularly during the mission, recommended: once per second | planned |
| `on_turn(m, turn)` | in matches, when a server turn arrives | planned |
| `on_wave_start(m)` | before a new wave | planned |
| `on_wave(m, wave)` | after a wave has started | planned |
| `on_vault_claimed(m, vault_index, value)` | when a knight vault has been captured | planned |
| `on_shard_taken(m, count)` | when a shard has been taken | planned |
| `on_tower_destroyed(m, kind)` | when a tower has been destroyed | planned |
| `on_fort_alarm(m, fort_index)` | when a fortress raises the alarm | planned |
| `on_rival_attacked(m, side)` | when a rival is attacked directly | planned |
| `on_rival_dead(m, side)` | when a rival's core is destroyed | planned |
| `on_cave_claimed(m, cave_index, chained)` | when a cave is claimed | planned |
| `on_freed(m, kind, cave_index)` | when a chained creature is freed | planned |
| `on_gate_damage(m, damage)` | when the Order's gate is hit | planned |
| `on_unit_reaches(m, unit, zone)` | when a creature reaches a trigger zone or a target | planned |
| `on_unit_crosses(m, unit, line)` | when a creature crosses a trigger line | planned |
| `on_branch(m, branch)` | when a branch goal has been reached and the next mission is decided | planned |
| `on_goal(m, goal)` | when a mission goal changes noticeably or is reached | planned |
| `on_end(m, won)` | mission end | planned |
### The `unit` object in location triggers
**Planned.** In `on_unit_reaches` and `on_unit_crosses` the script receives a small `unit` object:
```rhai
unit.id
unit.kind
unit.level
unit.side
unit.alive
unit.x
unit.y
```
Example:
```rhai
if unit.kind == "Ogre" && unit.side == global::PLAYER {
m.log("An ogre has reached the zone.");
}
```
`zone` is the name of the zone placed in the editor or defined in the script:
```rhai
if zone == "north_gate" {
// only react to this zone
}
```
Trigger zones should always be guarded by flags or counters so that the same action does not fire several times.
### Mission branches in the script
**Planned.** If a mission has branch goals, the script can react to a reached branch:
```rhai
fn on_branch(m, branch) {
m.log("Two paths: " + branch.id);
m.objective("The story continues on the path " + branch.id);
}
```
A branch object can later have these fields (the same as a branch in the mission data):
```rhai
branch.id
branch.title
branch.goal
branch.next
```
Example for two alternative paths:
```rhai
fn on_branch(m, branch) {
if branch.goal == "KillAll" {
m.log("The guardroom is destroyed.");
m.objective("The path leads to the patrol routes.");
}
if branch.goal == "Loot" {
m.log("The vaults are looted.");
m.objective("The path leads to the gold chambers.");
}
}
```
The branches themselves (choosing the next mission by goal) are implemented in the campaign data; see `docs/CAMPAIGN_EDITOR.md`.
## 7. Basic rule: everything must be repeatable
**Planned** (flags, `on_fort_alarm`, `m.play`). Events can fire several times. A script should therefore use flags or counters as a matter of habit.
Example: an alarm should fire exactly once.
```rhai
fn on_fort_alarm(m, fort_index) {
let key = "alarm_fort_" + fort_index.to_string();
if !m.has_flag(key) {
m.set_flag(key);
m.log("The guard has raised the alarm!");
m.play("alarm");
}
}
```
Helper pattern for one-time actions:
```rhai
fn once(m, key, action) {
if !m.has_flag(key) {
m.set_flag(key);
action.call();
}
}
```
Usage:
```rhai
fn on_start(m) {
once(m, "intro", || {
m.log("Crookfinger: We have guests.");
m.objective("Explore the shaft.");
});
}
```
A Rhai closure is called with `action.call()`.
## 8. Example 1: Minimal start script
Works today up to `on_goal`, which is **planned** (it is never called yet).
```rhai
fn on_start(m) {
m.log("Crookfinger: A small outpost, but maybe a shard.");
m.objective("Find the shard.");
}
fn on_goal(m, goal) {
if goal.name == "Shards" && goal.done {
m.log("Shard secured.");
m.objective("The way to the citadel is open.");
}
}
```
## 9. Example 2: Timed reinforcements
**Planned** (`on_time`, `m.time`, flags, `m.warn`).
This mission makes knights appear from the east after five minutes. After ten minutes a second group comes.
```rhai
fn on_time(m) {
if m.time >= 300 && !m.has_flag("wave_5min") {
m.set_flag("wave_5min");
m.log("Reinforcements from the east.");
m.spawn_at("HEROES", "Knight", 67.5, 38.5, 1, 2);
m.spawn_at("HEROES", "Archer", 67.5, 44.5, 1, 2);
}
if m.time >= 600 && !m.has_flag("wave_10min") {
m.set_flag("wave_10min");
m.log("Now they mean it.");
m.spawn_at("HEROES", "Knight", 71.5, 36.5, 1, 3);
m.spawn_at("HEROES", "Knight", 71.5, 48.5, 1, 3);
m.warn("Second wave!");
}
}
```
## 10. Example 3: Three phases of a mission
**Planned** (`on_time`, `m.time`, flags, `m.warn`).
This mission has three stages. Each stage fires exactly once.
```rhai
fn phase_name(m, phase) {
if phase == 1 {
return "First phase";
}
if phase == 2 {
return "Second phase";
}
return "Third phase";
}
fn on_time(m) {
let phase = 0;
if m.time >= 900 {
phase = 3;
} else if m.time >= 600 {
phase = 2;
} else if m.time >= 300 {
phase = 1;
}
let key = "phase_" + phase.to_string();
if phase > 0 && !m.has_flag(key) {
m.set_flag(key);
m.log(phase_name(m, phase));
if phase == 1 {
m.spawn_wave(3);
m.objective("Hold off the first small wave.");
}
if phase == 2 {
m.spawn_wave(5);
m.spawn_at("HEROES", "Paladin", 77.5, 40.5, 1, 2);
m.objective("Beat back the reinforcements.");
}
if phase == 3 {
m.spawn_wave(8);
m.warn("The Order comes with everything it has.");
m.objective("Survive the last assault.");
}
}
}
```
## 11. Example 4: Vault triggers a boss
**Planned** (`on_vault_claimed`, counters, flags, `m.warn`).
When the player loots enough vaults, a strong enemy appears.
```rhai
fn on_vault_claimed(m, vault_index, value) {
m.log("Vault " + vault_index.to_string() + " is open: " + value.to_string());
m.bump("opened_vaults");
if m.counter("opened_vaults") == 2 {
m.set_flag("second_vault_alarm");
m.log("The guardian is summoned.");
m.spawn_at("HEROES", "Paladin", 79.5, 40.5, 1, 4);
m.warn("A paladin approaches.");
}
if m.counter("opened_vaults") >= 4 {
m.log("The Order sends everything it has left.");
m.spawn_wave(6);
}
}
```
## 12. Example 5: Boss fight with several phases
**Planned** (`on_tower_destroyed`, counters, flags, `m.warn`).
A paladin is supported by more and more knights.
```rhai
fn boss_phase(m, phase) {
if phase == 1 {
m.spawn_at("HEROES", "Knight", 73.5, 34.5, 1, 3);
m.spawn_at("HEROES", "Knight", 73.5, 46.5, 1, 3);
m.log("The paladin is escorted by knights.");
}
if phase == 2 {
m.spawn_at("HEROES", "BattleMage", 79.5, 40.5, 1, 3);
m.warn("A battle mage enters the field.");
}
if phase == 3 {
m.spawn_at("HEROES", "Giant", 75.5, 40.5, 1, 3);
m.log("A giant smashes the first traps.");
}
}
fn on_tower_destroyed(m, kind) {
let key = "tower_" + kind.to_string();
if !m.has_flag(key) {
m.set_flag(key);
m.bump("towers_destroyed");
}
let destroyed = m.counter("towers_destroyed");
if destroyed >= 1 && !m.has_flag("boss_1") {
m.set_flag("boss_1");
boss_phase(m, 1);
}
if destroyed >= 3 && !m.has_flag("boss_2") {
m.set_flag("boss_2");
boss_phase(m, 2);
}
if destroyed >= 5 && !m.has_flag("boss_3") {
m.set_flag("boss_3");
boss_phase(m, 3);
}
}
```
## 13. Example 6: Letting rivals react
**Planned** (`on_rival_attacked`, `on_rival_dead`, counters, flags).
A rival strikes back as soon as the player becomes too aggressive.
```rhai
const MOSSGRIM = 2;
const NIGHTWELL = 3;
fn on_rival_attacked(m, side) {
m.bump("rival_pressure");
if m.counter("rival_pressure") >= 3 && !m.has_flag("mossgrim_retaliation") {
m.set_flag("mossgrim_retaliation");
m.log("Mossgrim feels your attack.");
m.spawn_wave(6);
}
if m.counter("rival_pressure") >= 6 && !m.has_flag("nightwell_retaliation") {
m.set_flag("nightwell_retaliation");
m.log("Lady Nightwell joins in.");
m.spawn_at(global::NIGHTWELL, "Owl", 55.5, 32.5, 1, 3);
m.spawn_at(global::NIGHTWELL, "Owl", 55.5, 48.5, 1, 3);
}
}
fn on_rival_dead(m, side) {
if side == global::MOSSGRIM {
m.log("Mossgrim's core has shattered.");
}
if side == global::NIGHTWELL {
m.log("Lady Nightwell's core has shattered.");
}
}
```
## 14. Example 7: Freed creatures call the guards
**Planned** (`on_freed`, flags, `m.warn`, position helpers with `spawn_at`).
When a chained creature is freed, guards come.
```rhai
fn on_freed(m, kind, cave_index) {
let key = "cave_freed_" + cave_index.to_string();
if !m.has_flag(key) {
m.set_flag(key);
m.log("The chains are broken.");
if kind == "Ogre" {
m.spawn_at("HEROES", "Knight", m.cave_pos(cave_index), 1, 2);
m.spawn_at("HEROES", "Knight", m.fort_pos(0), 1, 2);
m.warn("The knights have noticed the escape.");
}
if kind == "Wyrm" {
m.spawn_at("HEROES", "Knight", m.fort_pos(0), 1, 3);
m.spawn_at("HEROES", "Priest", m.fort_pos(0), 1, 3);
m.log("A wyrm is free - and the guards come anyway.");
}
}
}
```
## 15. Example 8: Randomness with safe repetition
**Planned** (`m.rand_choice`, variables, `on_time`, flags).
A patrol route is chosen at random, but the mission stays reproducible because `m.rand_choice` is used.
```rhai
fn on_start(m) {
let path = m.rand_choice(["north", "south"]);
m.set("patrol_path", path);
m.log("The patrol takes the route: " + path.to_string());
}
fn on_time(m) {
if m.time < 300 || m.has_flag("patrol_arrived") {
return;
}
let path = m.get("patrol_path");
if path == "north" {
m.spawn_at("HEROES", "Knight", 67.5, 24.5, 1, 2);
m.spawn_at("HEROES", "Archer", 69.5, 29.5, 1, 2);
}
if path == "south" {
m.spawn_at("HEROES", "Knight", 67.5, 56.5, 1, 2);
m.spawn_at("HEROES", "Archer", 69.5, 51.5, 1, 2);
}
m.set_flag("patrol_arrived");
}
```
## 16. Example 9: Time limit warnings
**Planned** (`on_time`, `on_end`, `m.time`, `m.time_limit`, flags, `m.warn`).
A mission with a time limit warns shortly before it runs out.
```rhai
fn on_time(m) {
if m.time_limit <= 0 {
return;
}
let left = m.time_limit - m.time;
if left <= 300 && !m.has_flag("warn_5min") {
m.set_flag("warn_5min");
m.warn("Five minutes left.");
}
if left <= 60 && !m.has_flag("warn_last_minute") {
m.set_flag("warn_last_minute");
m.warn("The last minute is running.");
}
}
fn on_end(m, won) {
if !won {
m.log("Time has run out.");
}
}
```
## 17. Example 10: Dynamic briefing texts
`on_start` works today; `on_goal` is **planned**.
A script can change objectives without restarting the mission file.
```rhai
fn on_start(m) {
m.objective("Explore the shaft.");
}
fn on_goal(m, goal) {
if goal.name == "Shards" && !goal.done {
m.objective("Shard found, but not secured yet.");
}
if goal.name == "Shards" && goal.done {
m.objective("Secure the way back to the core.");
}
if goal.name == "Survive" && goal.done {
m.objective("The waves are beaten.");
}
}
```
### Example 11: Drawn zone: the wall falls, enemies appear
**Planned** (drawing in the editor, `load_editor_zones`, `on_unit_reaches`, flags, `m.warn`, `destroy_walls_in_zone`, `m.fort_pos`). The zone data itself already works.
A circle named `north_gate` is drawn in the level editor (tile coordinates):
```json
"zones": [
{
"name": "north_gate",
"shape": "circle",
"at": [67, 40],
"radius": 3.0
}
]
```
The mission script loads the editor zones automatically:
```rhai
const HEROES = 1;
const PLAYER = 0;
fn on_start(m) {
m.load_editor_zones();
m.objective("Find the way into the fortress.");
}
fn on_unit_reaches(m, unit, zone) {
if zone != "north_gate" {
return;
}
if unit.side != global::PLAYER {
return;
}
if m.has_flag("north_gate_opened") {
return;
}
m.set_flag("north_gate_opened");
m.log("A creature has reached the north gate.");
m.warn("The wall breaks!");
m.destroy_walls_in_zone("north_gate");
m.spawn_count_in_zone("north_gate", "Knight", 3, global::HEROES, 2);
m.spawn_at(global::HEROES, "Archer", m.fort_pos(0), 1, 3);
}
```
If the editor writes the zones into the script automatically, `on_start(m)` can simply look like this:
```rhai
fn on_start(m) {
editor_zones(m);
m.objective("Find the way into the fortress.");
}
```
The editor-generated part would then be:
```rhai
// --- editor-generated zones: normally not edited manually ---
fn editor_zones(m) {
m.add_zone("north_gate", m.at(0.70, 0.50), 3.0);
}
```
### Example 12: Marked tiles as a zone
**Planned** (`load_editor_zones`, `on_unit_reaches`, flags, `m.warn`, `destroy_doors_in_zone`, `add_tiles_zone`). The zone data and `spawn_count_in_zone` already work.
Several tiles are marked in the level editor, for example a path to a vault:
```json
"zones": [
{
"name": "vault_path",
"shape": "tiles",
"tiles": [[34, 40], [34, 41], [35, 40], [35, 41]]
}
]
```
The script reacts as soon as a player creature steps on these marked tiles:
```rhai
const HEROES = 1;
const PLAYER = 0;
fn on_start(m) {
m.load_editor_zones();
m.log("Crookfinger has marked a path.");
}
fn on_unit_reaches(m, unit, zone) {
if zone != "vault_path" {
return;
}
if unit.side != global::PLAYER {
return;
}
if m.has_flag("vault_alarm") {
return;
}
m.set_flag("vault_alarm");
m.log("Someone has entered the marked path.");
m.warn("Vault alarm!");
m.destroy_doors_in_zone("vault_path");
m.spawn_count_in_zone("vault_path", "Knight", 2, global::HEROES, 3);
}
```
If the zone is not placed in the editor, the same zone can also be defined in the script:
```rhai
fn on_start(m) {
m.add_tiles_zone("vault_path", [
[34, 40],
[34, 41],
[35, 40],
[35, 41]
]);
}
```
### Example 13: Trigger line - a creature crosses a bridge
**Planned** (`load_editor_zones`, `on_unit_crosses`, counters, `m.warn`). The line zone data already works.
A line across a bridge is drawn in the editor (tile coordinates):
```json
"zones": [
{
"name": "bridge_path",
"shape": "line",
"from": [58, 40],
"to": [69, 40],
"width": 1.0
}
]
```
The script sets off ambushes when a creature crosses the bridge:
```rhai
const PLAYER = 0;
fn on_start(m) {
m.load_editor_zones();
m.objective("The bridge is not safe.");
}
fn on_unit_crosses(m, unit, line) {
if line != "bridge_path" {
return;
}
if unit.side != global::PLAYER {
return;
}
if m.counter("bridge_ambushes") >= 2 {
return;
}
m.bump("bridge_ambushes");
m.log("The bridge is being watched.");
m.warn("Ambush!");
m.spawn_at("HEROES", "Knight", 69.5, 36.5, 1, 2);
m.spawn_at("HEROES", "Archer", 71.5, 44.5, 1, 2);
}
```
## 18. Mission definition with script and briefing
A complete example. **Planned** parts: the `hints` field and scripts in separate files (today the script goes into `"script": { "language": "rhai", "source": "..." }`), and every hook except `on_start`.
```json
{
"key": "custom01",
"title": "The first trap",
"story": "The guards believe the old shaft is safe. They are wrong. Crookfinger has built a trap, but it needs bait.",
"hints": [
"Towers do not walk away on their own.",
"Knight ground can only be claimed when no knights stand next to it."
],
"size": [96, 80],
"seed": 123456,
"gold": 2500,
"goals": ["Shards", "Survive(3)"],
"zones": [
{
"name": "north_gate",
"shape": "circle",
"at": [67, 40],
"radius": 3.0
},
{
"name": "vault_path",
"shape": "tiles",
"tiles": [[34, 40], [34, 41], [35, 40], [35, 41]]
}
],
"script": "missions/custom01.rhai"
}
```
With `missions/custom01.rhai`:
```rhai
fn on_start(m) {
m.log("Crookfinger: We have guests.");
m.objective("Find the shard before they notice that the trap is open.");
}
fn on_goal(m, goal) {
if goal.name == "Shards" && goal.done {
m.log("Shard found.");
m.objective("Hold out against three short waves.");
m.spawn_wave(1);
}
}
fn on_wave(m, wave) {
if wave == 2 {
m.spawn_at("HEROES", "Knight", 67.5, 32.5, 1, 2);
}
if wave == 3 {
m.spawn_at("HEROES", "Knight", 67.5, 38.5, 1, 2);
m.spawn_at("HEROES", "Knight", 67.5, 45.5, 1, 2);
m.warn("Last wave!");
}
}
```
## 19. Rules for multiplayer and determinism
The game simulation is deterministic in matches. Scripts must not break that determinism.
Therefore:
1. No local random decisions that happen on only one client.
2. Only use `m.rand_int`, `m.rand_choice` or other well-defined random functions of the mission API.
3. No timed events that depend on FPS, camera, mouse, keyboard or graphics.
4. `on_time` should only run mission logic and not write graphical effects directly into the state.
5. Change creatures, waves and goals only through the released mission API.
6. For matches, scripts can later run so that all clients have the same mission and the same seed.
Today `on_start` runs once when a custom campaign mission starts, and Rhai itself has no access to time or randomness here (`no_time` is enabled), so the current runtime is deterministic.
## 20. Recommended style
### Prefer short scripts
Good (**planned** API):
```rhai
fn on_time(m) {
if m.time >= 300 && !m.has_flag("second_wave") {
m.set_flag("second_wave");
m.spawn_wave(4);
}
}
```
Bad:
```rhai
fn on_time(m) {
for i in 0..1000000 {
// too slow and dangerous in the simulation
}
}
```
The engine stops a script after 200,000 operations with an error.
### Name flags after the mission phase
**Planned** API.
Good:
```rhai
m.set_flag("phase2_started");
m.has_flag("phase2_started");
```
Less good:
```rhai
m.set_flag("flag");
```
### Counters for repeated conditions
**Planned** API.
Good:
```rhai
m.bump("vaults_opened");
if m.counter("vaults_opened") >= 3 {
m.spawn_wave(4);
}
```
### Clear names for creatures and sides
Good:
```rhai
m.spawn_at("HEROES", "Knight", 67.5, 40.5, 1, 2);
```
Not possible:
```rhai
m.spawn_at(1, 13, 67.5, 40.5, 1, 2);
```
Creature kinds are only accepted as names. Sides may be numbers, but names read better.
## 21. Complete sample script for a mission
**Planned** - uses many planned hooks and functions (`on_time`, `on_goal`, `on_rival_attacked`, `on_end`, `m.time`, flags, `m.warn`, `m.rand_choice`).
```rhai
const MOSSGRIM = 2;
fn start_intro(m) {
m.log("Crookfinger: This cave is not empty.");
m.objective("Find the shard and survive the waves.");
}
fn alarm(m, key, text) {
if !m.has_flag(key) {
m.set_flag(key);
m.log(text);
m.warn(text);
}
}
fn reinforce_from_north(m) {
m.spawn_at("HEROES", "Knight", 67.5, 28.5, 1, 2);
m.spawn_at("HEROES", "Archer", 69.5, 25.5, 1, 2);
}
fn reinforce_from_south(m) {
m.spawn_at("HEROES", "Knight", 67.5, 52.5, 1, 2);
m.spawn_at("HEROES", "Archer", 69.5, 54.5, 1, 2);
}
fn on_start(m) {
start_intro(m);
}
fn on_time(m) {
if m.time >= 300 {
alarm(m, "alarm_5min", "After five minutes the guards hear something.");
let roll = m.rand_choice(["north", "south"]);
if roll == "north" {
reinforce_from_north(m);
} else {
reinforce_from_south(m);
}
}
if m.time >= 900 {
alarm(m, "alarm_15min", "The second group is coming.");
m.spawn_wave(2);
}
}
fn on_goal(m, goal) {
if goal.name == "Shards" && goal.done {
m.log("The shard is ours.");
m.objective("Hold out until the last waves come.");
m.spawn_wave(1);
}
if goal.name == "Survive" && goal.done {
m.log("The assault is beaten.");
}
}
fn on_rival_attacked(m, side) {
if side == global::MOSSGRIM && !m.has_flag("mossgrim_help") {
m.set_flag("mossgrim_help");
m.log("Mossgrim hears the noise.");
m.spawn_wave(3);
}
}
fn on_end(m, won) {
if won {
m.log("Crookfinger: Well done. The core becomes whole.");
} else {
m.log("Crookfinger: We started too early.");
}
}
```
Note: as in the original design, the `m.time >= 300` and `m.time >= 900` branches would run every second once reached; the reinforcements are not guarded by a flag, and `m.spawn_wave(2)` repeats. A real script should guard them like `alarm()` does.
## 22. What the editor must be able to do later
The level editor should be able to edit at least the following. Items marked "(done)" exist in the campaign editor today; everything else is **planned**.
1. Mission data:
- `key` (generated when a mission is added)
- `title` (done)
- `story`
- `hints`
- `size`
- `seed`
- `gold`
- `goals` (done)
- `portals`
- `treasures`
- `waves`
- `wave_limit`
- `first_wave`
- `time_limit`
- `next` and branches (done)
2. Campaign structure:
- Add and remove missions (done), reorder them.
- Edit mission title (done), story and hints.
- Set the campaign name (done).
- Save and load missions as JSON (done, `.dccamp`).
3. Map editing:
- Set the size.
- Adjust the terrain.
- Edit rivers and lakes.
- Place caves.
- Place mana springs.
- Place gates.
- Place and edit fortresses.
- Set the player's starting points.
- Place trigger zones graphically: circle, rectangle, polygon, marked tiles, line/bridge.
- Name zones, set radius or tile set and add them to the script automatically.
- Optionally filter zones: player side only, knight side only, certain creatures, fire once.
4. Script editing:
- Choose a script file or write the script in the editor (done: single-line prompt, up to 600 characters).
- Generate or update editor trigger zones in the script automatically.
- Move, delete or rename graphically placed zones in the editor and update the script.
- Syntax check with Rhai (done).
- Start a test mission directly (done: start a mission from the editor).
- Show script errors:
- unknown function (done, through Rhai when `on_start` runs)
- unknown creature (done)
- unknown zone (done)
- invalid coordinate
- invalid goal (done for the mission's `goals`, not inside scripts)
- endless loop (done, operation limit)
- unknown side name
5. Briefing:
- Title
- Story text
- Hints
- Objectives
- Optional language/locale keys
## 23. Error examples
The editor should show these errors. The examples use the implemented functions where possible.
```rhai
fn on_start(m) {
m.spawn_at("HEROES", "UnknownCreature", 48.5, 40.5, 1, 2);
}
```
Error: unknown creature. Implemented: rejected by the editor validation.
```rhai
fn on_start(m) {
m.spawn_count_in_zone("no_such_zone", "Knight", 3, "HEROES", 2);
}
```
Error: unknown zone. Implemented: rejected by the editor validation.
```rhai
fn on_start(m) {
m.spawn_at("HEROES", "Knight", 192.5, 40.5, 1, 2);
}
```
Error: coordinate outside the map. **Not reported yet**: the spawn is skipped silently.
```rhai
fn on_start(m) {
while true {
m.spawn_wave(1);
}
}
```
Error: endless loop or too many repetitions. Implemented: Rhai stops after 200,000 operations and the script is rejected.
```rhai
fn on_start(m) {
m.unknown_function();
}
```
Error: unknown mission function. Implemented: Rhai reports "function not found" when `on_start` runs, so the editor rejects it. Inside hooks that are not called during validation, it would only show up in game.
```rhai
fn on_start(m) {
m.spawn_at("HEROES", "Knight", 48, 40, 1, 2);
}
```
Error: wrong argument types. Implemented: Rhai reports "function not found" because `x` and `y` must be floats (`48.0`, `40.0`).
## 24. Summary for level authors
A mission script usually has four parts (only `on_start` is called today):
```rhai
fn on_start(m) {
m.objective("Short objective.");
}
fn on_time(m) {
// timed events (planned)
}
fn on_goal(m, goal) {
// react to mission progress (planned)
}
fn on_unit_reaches(m, unit, zone) {
// react to graphically placed zones (planned)
}
```
The most important tools:
```rhai
// implemented
m.log("Text");
m.objective("Objective");
m.spawn_at("HEROES", "Knight", 67.5, 40.5, 1, 2);
m.spawn_count_in_zone("north_gate", "Knight", 3, "HEROES", 2);
m.spawn_wave(3);
// planned
m.has_flag("phase1_done");
m.set_flag("phase1_done");
m.counter("vaults_opened");
m.bump("vaults_opened");
m.load_editor_zones();
m.add_zone("north_gate", m.at(0.70, 0.50), 3.0);
m.destroy_walls_in_zone("north_gate");
```
With these, many missions can be built: waves, boss phases, reactions, vault events, rivals, time limits, location triggers and dynamic briefings.