Script mods
Les guides de mods sont en anglais, tels qu'ils sont livrés avec le jeu.
A mod can carry a script, in GDScript or in Lua, that the game calls when things happen in a run and that acts through one object, api. It is the same idea as a Factorio mod's control.lua. Data files (balance.json, waves.json...) still set the numbers; a script adds rules that numbers cannot express: a bounty every tenth kill, towers that grow stronger with their kills, a spark in the rain. Both languages get the same hooks, the same API and the same capabilities; Lua scripts below lists what differs.
Samples: _example_script_bounty (GDScript) and _example_lua_bounty (the same rules in Lua) in the mods folder; rename one without the underscore to try it. Each is about seventy lines and uses most of the API.
The declaration
In mod.json:
"script": {
"entry": "control.gd",
"api_version": 1,
"capabilities": ["gold", "enemies", "towers", "notify"]
}
entryis a.gdor a.luafile inside the mod's folder; its extension picks the language.api_versionis the API the script was written for. The game refuses a script that asks for a newer one.capabilitiesare the kinds of change the script may make. Reading the game needs none. A call that needs a capability the mod did not declare is refused and logged.
| Capability | What it allows |
|---|---|
gold | api.gold.add |
lives | api.lives.add |
enemies | api.enemy.damage, api.enemy.slow |
towers | api.tower.stat_mult |
spawn | api.waves.spawn |
notify | api.notify |
The player allows a script before it runs. The Mods screen marks a script mod [SCRIPT] and lists what it may do. A script mod nobody allowed stays off, whole: its data files do not load without its script. Ticking it opens a question naming its capabilities. If a later version asks for more capabilities, the player is asked again.
A script mod is a gameplay mod: it plays on the mod profile, keeps achievements and leaderboards off, and its .gd file is part of the content hash every multiplayer lobby compares.
The script
extends TorretaMod
const BOUNTY_EVERY := 10
func on_enemy_killed(enemy: Dictionary, tower: Dictionary) -> void:
var kills: int = int(api.storage.get("kills", 0)) + 1
api.storage["kills"] = kills
if kills % BOUNTY_EVERY == 0:
api.gold.add(5, "bounty")
The first line of code is extends TorretaMod, alone. Define only the hooks you need. Every payload is a dictionary of plain values (numbers, strings, bools). It is a copy: changing it changes nothing, except on_damage's amount.
Hooks
| Hook | When | Payload |
|---|---|---|
on_load() | the game compiled the script (no run yet) | none |
on_run_start(run) | a run begins, new or continued | mode ("solo", "pvp", "coop"), difficulty, wave, continued |
on_run_end(run) | a defeat, or the menu, Restart or quit | result ("lost" or "left"), wave |
on_wave_start(wave) | a wave starts walking | number, boss, enemies |
on_wave_end(wave) | the wave's last insect is gone | number |
on_enemy_spawned(enemy) | an insect walks out | an enemy dictionary |
on_enemy_killed(enemy, tower) | an insect died | the enemy, and the killer tower's dictionary or {} |
on_enemy_leaked(enemy) | an insect reached the cave | an enemy dictionary |
on_damage(hit) | a tower's hit is about to land | enemy (id), tower_id, tower_kind, tower_level, amount, original |
on_tower_built(tower) | a tower was built | a tower dictionary |
on_tower_upgraded(tower) | a tower rose a level or bought a path tier | a tower dictionary |
on_tower_sold(tower) | the player sold a tower | id, kind, level, x, y |
on_card_taken(card) | a card was taken | card (its id), group |
on_tick(tick) | ten times a game second | dt (game seconds since the last) |
on_damage may set hit.amount; the game holds it between 0 and ten times original. Several mods chain: each sees the amount the one before it left.
An enemy dictionary: id, kind, mod_type, hp, max_hp, shield, max_shield, boss, flyer, cloaked, speed, minion, troop, life_damage, x, y, wave. Ids count up from 1 in each run.
A tower dictionary: id, kind, level, fused, damage, interval (seconds between shots), range, x, y: its own numbers, before cards and weather. A tower's id is its cell. An upgrade and a Continue keep it, both co-op players agree on it, and a new tower built on a sold one's cell takes it.
The API
| Call | Returns or does |
|---|---|
api.gold.value() / api.gold.add(amount, reason) | the gold; add or take up to 10,000 at a time |
api.lives.value() / api.lives.add(amount) | the lives; give or take up to 10, never the last |
api.enemies.list() / api.enemies.count() | the living insects (enemy dictionaries) |
api.enemy.info(id) | one insect, or {} once it is gone |
api.enemy.damage(id, amount) | up to 100,000, shield first, no tower credited |
api.enemy.slow(id, factor, seconds) | its pace times factor (0.1-1) for up to 30 s |
api.towers.list() / api.towers.count() | the standing towers |
api.tower.info(id) | one tower, or {} |
api.tower.stat_mult(id, stat, mult) | this mod's multiplier on "damage", "rate" (of fire; above 1 is faster) or "range", 0.25-4; 1 removes it |
api.waves.current() / api.waves.active() | the wave in play; whether one walks |
api.waves.spawn(count) | up to 50 more of the wave's first group (a boss wave's boss), at its spacing |
api.rng.unit(), float_range(a, b), int_range(a, b), chance(p) | numbers from the run's seed and the mod's id |
api.info.mode(), difficulty(), time(), raining() | what the run is |
api.notify(text) | a notice on screen, at most one a second |
api.debug(text) | a line in the log, marked with the mod's id |
api.storage | your own dictionary, saved with the run |
api.version() | the API version the game answers to |
A tower's multipliers stay with its cell until it is sold, and Continue keeps them. Several mods' multipliers multiply.
Storage and random numbers
Put what the mod must remember in api.storage: plain values only (numbers, strings, bools, arrays, dictionaries with string keys), at most 20,000 of them. It is saved with the run and is back in on_run_start when run.continued is true. A new run starts with an empty storage.
Draw random numbers only from api.rng. It is seeded by the run's seed and the mod's id, and Continue resumes it where it stopped. Both co-op devices run the script on the same board, so a script that draws anywhere else would make the two disagree. randi, randf and their kind are not allowed.
What a script may write
The game reads a script as text and refuses it before compiling unless every name in it is allowed:
- names it declares itself: variables, constants, functions, parameters, loop variables, enum members, signals;
apiand the API's names;- safe built-in types and functions (
Vector2,Array,Dictionary,min,clamp,str,floori,print...) and their members (size,append,keys,get...); - the payloads' keys after a dot (
hit.amount). Your own keys go in brackets:api.storage["kills"], notapi.storage.kills.
There is no OS, FileAccess, load, preload, get_tree, no autoload, no call, Callable, await, inner class or annotation. The Mods screen's Publish runs the same check and refuses to upload a script that fails it, naming the line.
Two GDScript habits to keep:
:=needs a typed value.var kills := 0andvar living := api.enemies.list()work; for a value out of a dictionary writevar kills: int = int(api.storage.get("kills", 0)).- Integer division warns: write
floori(float(a) / b).
When a script fails
Each hook is timed, and errors raised from the script's file are counted. A mod that errors ten times, or runs past 8 ms twenty times, is switched off for the rest of the run, with a notice; the other mods go on. A hook that fails stops at the failing line and the game carries on. In GDScript the game cannot stop a loop that never ends: test your loops. In Lua the instruction budget stops it.
Lua scripts
local BOUNTY_EVERY = 10
function on_enemy_killed(enemy, tower)
local kills = (api.storage.kills or 0) + 1
api.storage.kills = kills
if kills % BOUNTY_EVERY == 0 then
api.gold.add(5, "bounty")
end
end
- Hooks are global functions with the same names and payloads (
function on_tick(tick) ... end). Payloads are tables;on_damagesetshit.amountas in GDScript. - Call the API with a dot,
api.gold.add(5): a colon (api.gold:add(5)) would pass the table as the amount. Lists from the API (api.enemies.list()) are Lua arrays, so the first insect is[1]. - The library is Lua 5.4's base,
string,table,mathandutf8. There is noio,os,require,load,dofile,debug,coroutineorstring.dump, andmath.randomis gone: draw fromapi.rng.printwrites throughapi.debug.pairswalks string and number keys in the same order on every machine. - The script runs in its own sandbox, not through the checker: anything it writes compiles, and it still reaches only
api. Precompiled bytecode is refused. - Each hook has a budget of 2,000,000 instructions, and the script a memory cap of 32 MB. Past the budget the hook stops with an error, even inside
pcall, so a loop that never ends comes back; ten such errors switch the mod off for the run. - Storage works the same: plain values in
api.storage. A number loaded by Continue may come back as a float (5.0);%,//andstring.format("%d")accept it. - Platforms: the runtime is a native library (
addons/torreta_lua). A game whose build lacks it for its platform refuses a Lua mod with a notice and loads GDScript mods as usual.
Multiplayer and bots
- In PvP a script runs on its own player's board, nowhere else.
- In co-op both devices run it on the shared board, so it must draw only from
api.rng. - Every player in a lobby needs the same script: it is part of the mod's hash.
- Bots stay off for a gameplay mod unless its
bots.jsonenables them, and a bot's board does not run scripts.
