44 KiB
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 berhai(or empty); any other value showsmsg.script_language. - One hook:
on_start(m), called once when the mission starts. A script withoutfn 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 integer0to4, 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, case-insensitive.x,y: floating point world coordinates in tiles (21.5is the centre of tile 21). Integer literals do not match:m.spawn_at("HEROES", "Knight", 21, 21, 3, 2)fails with "function not found"; write21.0or21.5. Positions outside the map are skipped silently.count,level,side(as number): integers.countandlevelare clamped to1..10;spawn_waveclampscountto1..20.zone: thenameof a zone in the mission'szoneslist (see 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_zonenames 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
spawnis a reserved word in Rhai. Evenm.spawn(...)is a parse error, so the API usesspawn_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 asglobal::HEROESinside a function; plainHEROESfails 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:
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:
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:
{
"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:
{
"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.
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:
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):
"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:
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:
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)):
Shards
Heart
KillAll
Loot
Towers
Survive
Rivals
Springs
Freed
Example:
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.
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.
m.play("alarm");
m.flash("danger");
m.flash("treasure");
Planned effect names:
alarm
danger
treasure
claim
level_up
heal
Variables, flags and counters
Planned.
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:
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:
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:
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.
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:
fn on_start(m) {
m.add_tiles_zone("vault_path", [
[34, 40],
[34, 41],
[35, 40]
]);
}
Rectangles and lines:
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:
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:
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:
// --- 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):
"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.
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.
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):
m.spawn_at(side, kind, x, y, count, level);
m.spawn_count_in_zone(zone, kind, count, side, level);
Example:
m.spawn_at("HEROES", "Knight", 21.5, 21.5, 3, 2);
m.spawn_count_in_zone("north_gate", "Knight", 3, "HEROES", 2);
m.spawn_at("HEROES", "Knight", 69.5, 38.5, 1, 2);
Groups through several calls:
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):
m.spawn_wave(6);
Planned:
m.set_wave_limit(8);
m.set_first_wave(180);
Changing goals and limits (planned):
m.set_time_limit(1200);
m.add_time_limit(300);
m.set_wave_limit(10);
Randomness
Planned. Only use the allowed random functions:
m.rand_int(1, 10)
m.rand_choice(["north", "south"])
Example:
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:
unit.id
unit.kind
unit.level
unit.side
unit.alive
unit.x
unit.y
Example:
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:
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:
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):
branch.id
branch.title
branch.goal
branch.next
Example for two alternative paths:
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.
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:
fn once(m, key, action) {
if !m.has_flag(key) {
m.set_flag(key);
action.call();
}
}
Usage:
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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):
"zones": [
{
"name": "north_gate",
"shape": "circle",
"at": [67, 40],
"radius": 3.0
}
]
The mission script loads the editor zones automatically:
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:
fn on_start(m) {
editor_zones(m);
m.objective("Find the way into the fortress.");
}
The editor-generated part would then be:
// --- 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:
"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:
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:
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):
"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:
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.
{
"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:
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:
- No local random decisions that happen on only one client.
- Only use
m.rand_int,m.rand_choiceor other well-defined random functions of the mission API. - No timed events that depend on FPS, camera, mouse, keyboard or graphics.
on_timeshould only run mission logic and not write graphical effects directly into the state.- Change creatures, waves and goals only through the released mission API.
- 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):
fn on_time(m) {
if m.time >= 300 && !m.has_flag("second_wave") {
m.set_flag("second_wave");
m.spawn_wave(4);
}
}
Bad:
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:
m.set_flag("phase2_started");
m.has_flag("phase2_started");
Less good:
m.set_flag("flag");
Counters for repeated conditions
Planned API.
Good:
m.bump("vaults_opened");
if m.counter("vaults_opened") >= 3 {
m.spawn_wave(4);
}
Clear names for creatures and sides
Good:
m.spawn_at("HEROES", "Knight", 67.5, 40.5, 1, 2);
Not possible:
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).
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.
-
Mission data:
key(generated when a mission is added)title(done)storyhintssizeseedgoldgoals(done)portalstreasureswaveswave_limitfirst_wavetime_limitnextand branches (done)
-
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).
-
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.
-
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_startruns) - 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
- unknown function (done, through Rhai when
-
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.
fn on_start(m) {
m.spawn_at("HEROES", "UnknownCreature", 48.5, 40.5, 1, 2);
}
Error: unknown creature. Implemented: rejected by the editor validation.
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.
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.
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.
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.
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):
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:
// 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.