Scripting
Event scripts
Real running code: Rhai callbacks that react to the match and return commands.
Rhai is a small embedded language with functions, loops, conditions, arrays and maps. Embed a script from your build script, and it compiles once when a match loads.
mod.script("mechanics", file="mechanics.rhai", tick_interval=60)
Your first script
fn on_new_game(ctx) { this.reward = 100; [] }
fn on_round_end(ctx) {
if ctx.sandbox { return []; }
this.reward += 10;
[#{op: "cash", amount: this.reward}]
}
Define only the callbacks you need. Each receives ctx and returns an array of commands.
this is a per-script, per-match map for your state (not a save file). Top-level statements don't run;
put helpers in functions, and call ones that use state as this.helper(ctx) so this stays bound.
Hooks
| Callback | Runs |
|---|---|
on_new_game | When the match starts |
on_round_start / on_round_end | At each round boundary |
on_tower_placed | After a placement; ctx.event is the tower snapshot |
on_tower_sold | After a sale; ctx.event is the pre-removal snapshot |
on_towers_changed | When the tower list changes |
on_pop | Once per simulation step, aggregating that step's pops |
on_tick | Every tick_interval simulation steps (60 per simulated second) |
on_query | When an inspect request is answered |
No callbacks run while paused, except explicit UI actions.
The ctx snapshot
| Field | Meaning |
|---|---|
cash | Current cash |
lives | Current lives |
round | Completed rounds |
tick | Simulation step |
popped | Pops (in on_pop, this step's) |
tower_count, bloon_count | Live counts |
sandbox | Whether this is a Sandbox match |
event | Hook-specific data, when present |
Commands
#{op: "cash", amount: 100} // add (negative subtracts)
#{op: "lives", value: 200} // set; doesn't revive an ended game
#{op: "spawn", name: "Blue", count: 30, spacing: 0.2}
#{op: "clear_bloons"}
#{op: "reset_cooldowns"}
#{op: "popup", title: "Hello", body: "Your message"}
#{op: "ui", widget: #{id: "panel", tree: node, actions: #{"Button": "on_click"}, modal: true}}
#{op: "remove_ui", id: "panel"}
#{op: "inspect", query: #{kind: "towers", offset: 0, limit: 8, tag: "list"}}
#{op: "entity_patch", patch: #{kind: "bloons", id: b.id, layer: b.layer, fields: #{hp: 50}}}
#{op: "damage_bloon", id: b.id, layer: b.layer, amount: 5}
#{op: "listen", event: "before_fire", enabled: true}
Commands in one callback are validated together before any apply. An invalid batch or an over-budget script disables that script for the match.
Limits
| Limit | Value |
|---|---|
| Operations per callback | 30,000 |
| Operations per simulation tick (all scripts) | 100,000 |
| Call depth | 16 |
| Commands per callback | 32 |
Persistent this state | 64 KiB |
| Script source | 32 KiB (inside the 64 KiB manifest) |
| Callback output | 256 KiB |
Scripts have no filesystem, network or process APIs. Runtime script mods work in local matches and are rejected in co-op until their state protocol exists.
A complete example is Mechanics Workshop.