WIKI

Mods

Script mods

Os guias de mods estão em inglês, do jeito que acompanham o jogo.

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"]
}
  • entry is a .gd or a .lua file inside the mod's folder; its extension picks the language.
  • api_version is the API the script was written for. The game refuses a script that asks for a newer one.
  • capabilities are 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.
CapabilityWhat it allows
goldapi.gold.add
livesapi.lives.add
enemiesapi.enemy.damage, api.enemy.slow
towersapi.tower.stat_mult
spawnapi.waves.spawn
notifyapi.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

HookWhenPayload
on_load()the game compiled the script (no run yet)none
on_run_start(run)a run begins, new or continuedmode ("solo", "pvp", "coop"), difficulty, wave, continued
on_run_end(run)a defeat, or the menu, Restart or quitresult ("lost" or "left"), wave
on_wave_start(wave)a wave starts walkingnumber, boss, enemies
on_wave_end(wave)the wave's last insect is gonenumber
on_enemy_spawned(enemy)an insect walks outan enemy dictionary
on_enemy_killed(enemy, tower)an insect diedthe enemy, and the killer tower's dictionary or {}
on_enemy_leaked(enemy)an insect reached the cavean enemy dictionary
on_damage(hit)a tower's hit is about to landenemy (id), tower_id, tower_kind, tower_level, amount, original
on_tower_built(tower)a tower was builta tower dictionary
on_tower_upgraded(tower)a tower rose a level or bought a path tiera tower dictionary
on_tower_sold(tower)the player sold a towerid, kind, level, x, y
on_card_taken(card)a card was takencard (its id), group
on_tick(tick)ten times a game seconddt (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

CallReturns 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.storageyour 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;
  • api and 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"], not api.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 := 0 and var living := api.enemies.list() work; for a value out of a dictionary write var 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_damage sets hit.amount as 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, math and utf8. There is no io, os, require, load, dofile, debug, coroutine or string.dump, and math.random is gone: draw from api.rng. print writes through api.debug. pairs walks 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); %, // and string.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.json enables them, and a bot's board does not run scripts.