Skip to content

Modding Taxation Evasion

The game loads its own content the same way it loads mods. Cars, cards, weapons, parts, paints, lockbox finds, maps, difficulties, run modifiers, enemy types, Safehouse upgrades, contract counters and the announcer's lines are rows in game/data/content/base/. A mod is a folder of the same files, and it can change any of those rows or add new ones. A mod can also add a language and translate the game's text.

  • This guide: installing mods, making your first one, and how mods fit together.
  • API reference: every game function, event and content field. It is generated from the game's code, so it is always current.
  • Examples: thirteen working mods to copy from. A test loads them on every change.

Installing a mod

Put the mod's folder in mods/ inside the save folder. The game creates mods/ on first launch.

  • Windows: %APPDATA%\com.taxsurvivor.love-native\mods\
  • Linux: ~/.local/share/com.taxsurvivor.love-native/mods/

On Steam, subscribe to a mod on the game's Workshop page instead. Steam downloads it, and the game loads it the next time it starts, after the mods in mods/. If a folder in mods/ has the same mod id as a subscription, the folder loads instead, so authors can work on a mod they have published.

Settings > CREDITS ends with a MODS section. It lists every mod that loaded, and every folder or Workshop item that was skipped with the reason. Set TAX_LOVE_MODS=0 to start without mods.

Your first mod: a new difficulty

  1. Make a folder mods/nightmare/.
  2. Add mod.json:
{"id": "nightmare", "title": "Nightmare difficulty", "api": 1}
  1. Add difficulties.json:
[{"id": "nightmare.nightmare", "numeral": "VI", "name": "NIGHTMARE",
  "short": "NIGHTMARE", "rule": "TWICE THE POLICE, ARMY TWO STARS SOONER",
  "payout": 3.5, "crowd": 2, "fewer_repairs": true, "army_shift": 2,
  "auditors": true, "cop_speed": 1.3}]
  1. Start the game. NIGHTMARE is now at the top of the difficulty ladder, and escaping at DEATH AND TAXES! unlocks it.

Art, sound and music

These files go in folders inside a mod, and they load without MOD CODE.

Folder File Becomes
sprites/ cab.png A new sprite, taxi.cab, that rows can name ("sprite": "taxi.cab")
sprites/ attack_bike.png A replacement for the game sprite of that name. It must be the same size.
sfx/ clank.wav A new sound, scrapyard.clank, for game.sound
sfx/ AirbrakesHiss.wav A replacement for the game sound of that name
music/ drive.ogg A soundtrack track, listed in the mod's music.json
  • New sprites are at most 256 by 256 pixels. They are packed into the game's sprite atlas at startup, along with the white copy used for the hit flash.
  • A car's art needs three sprites: the body, a _paint mask that takes the chosen paint, and an _invulnerable frame. Name them cab.png, cab_paint.png and cab_invulnerable.png, then use "sprite": "taxi.cab". The taxi example does this.
  • To find a game sprite's name and size, look in game/data/sprite_atlas.json.
  • A replacement brings its masks with it. The game rebuilds the masks it makes from a sprite (a car's _paint and _invulnerable, an art sprite's _mask) from your file, so a reskinned car still takes paint and flashes. Ship one of those files yourself to draw it by hand.
  • Mod sprites light like the game's own at night. There is no separate night mask to make.
  • Music rows give a title and artist for the tape player: {"id": "neon.drive", "title": "DRIVE", "artist": "YOU", "file": "drive.ogg"}. Add {"id": "mixtape_awake", "enabled": false} to drop a game track. For a radio pack, drop them all.
  • A mod with only art, sound, music and paints counts as cosmetic. Its runs stay comparable with unmodded ones.

Balance: hard mode, double cash, faster cops

balance.json changes the game's tuning numbers. Each row names a system (economy, pursuit, handling or enemies) and gives only the values it changes:

[
  {"id": "pursuit", "wanted_at": [0, 15, 40, 70, 100]},
  {"id": "enemies", "cop_car_hp": 9},
  {"id": "economy", "upgrade_base": 120, "style_cash": {"near_miss": 2}}
]
  • wanted_at gives the second each wanted level starts, so this makes the wanted level climb twice as fast.
  • Lists with five entries hold one value per wanted level, I to V.
  • In an object such as style_cash, only the keys you give change.
  • The reference lists every value with its default and range. The hard mode example puts several together.
  • Difficulties still apply on top, so a balance mod makes every difficulty harder or easier.

Run modifiers

The setup page has a MODIFIERS button next to DRIVE. It opens a list of switches such as ONE HEART, NO NOS and DOUBLE CASH, and the player can turn on any mix of them at any difficulty. modifiers.json adds new switches:

[
  {"id": "rush_hour.rush_hour", "name": "RUSH HOUR", "description": "HALF AGAIN AS MANY POLICE",
   "payout": 1.5, "crowd": 1.5},
  {"id": "rush_hour.armed", "name": "ARMED", "description": "START WITH THE MACHINE GUN AND SHOTGUNS",
   "payout": 0.75, "start_cards": ["machine_gun", "side_shotguns"]}
]
  • payout multiplies the cash the run banks. Use more than 1 for a harder run and less than 1 for an easier one. The payouts of every switched-on modifier multiply together, and with the difficulty's.
  • Rules from several modifiers combine. Hearts take the lowest value, while nos, cash and crowd multiply.
  • For a rule the fields cannot express, give an effect. It is a game.effect that runs in every run with the modifier, as in the scrapyard example.
  • The reference lists every field. The rush hour example also changes a base modifier's payout.

Weapons

A weapon is a gear card plus a row in weapons.json with the same id. The row names a pattern, which decides how the weapon aims and fires, and sets its numbers. Every base weapon is a row on its own pattern, from turret (machine gun) and drone (drone rack) to railgun, sentries and ram (the reinforced bumper). The reference lists all sixteen.

The card, in cards.json:

[
  {"id": "arsenal.nail_gun", "name": "NAIL GUN", "tier": "gear", "branch": "nails", "max": 1, "icon": 12,
   "description": "Rattles nails at the nearest enemy: fast, light and long bursts."},
  {"id": "arsenal.nail_feed", "name": "NAIL FEED", "tier": "part", "branch": "nails", "max": 3, "icon": 9,
   "requires": "arsenal.nail_gun", "description": "+20% nail gun fire rate."}
]

The gun, in weapons.json:

[
  {"id": "arsenal.nail_gun", "pattern": "turret", "range": 120, "interval": 0.12, "damage": 0.4,
   "speed": 520, "burst": 5, "color": [190, 200, 215],
   "upgrades": {"arsenal.nail_feed": {"rate": 0.2}}}
]
  • The gear card is offered in runs like the base guns. Reload parts, haste and the crit cards work on it, and the results screen credits its damage to it. A lockbox find that unlocks the card keeps it out of runs until it is found.
  • upgrades says what each of the weapon's own cards adds per rank. Give those cards requires the gear card, so they are offered only once it is owned.
  • A new row takes every field it leaves out from its pattern's base weapon, except upgrades. This row is a second drone rack with three faster, lighter drones, and a card that switches on the pattern's hunter trait:
{"id": "arsenal.wasp_nest", "pattern": "drone", "count": 3, "interval": 0.4, "damage": 1,
 "upgrades": {"arsenal.wasp_hunter": {"hunter": true}}}

Each pattern has its own traits, such as the railgun's ricochet or the mine layer's chain. A trait is switched on while its card is owned. - A row with a base id retunes that weapon, for example {"id": "exhaust_gun", "rounds": 4, "spread": 75} or {"id": "ram", "damage": 3}. Its pattern cannot change. A patch's upgrades adds cards or replaces them by id, and the weapon keeps the rest. - A code mod can give a base pattern a new trait with game.trait, then switch it on from an upgrade card like the pattern's own. The scrapyard example adds SCRAP COILS to the game's drone rack, so every fifth zap hits three times as hard. - When none of the patterns fits, a code mod registers its own with game.weapon and fires rounds with game.fire. The scrapyard example throws a ring of scrap. - The reference describes each pattern and its traits and every field. The arsenal example is the mod above, with the wasps and that exhaust gun retune.

Two ways to mod, one set of rules

Data mods are JSON files. They are safe to share and always load.

Code mods add Lua scripts, listed in mod.json "scripts". Scripts can do everything a data mod can, through game.add and game.change. They can also register behaviour such as card effects, car perks, enemy AI, weapon patterns and difficulty rules. Scripts have full access to the game and the computer, like Minecraft Java mods. For that reason they load only after the player turns on Settings > CREDITS > MOD CODE. It is off by default and applies on the next launch. TAX_LOVE_MOD_CODE=1 turns it on for one launch.

These rules hold either way:

  • A row whose id already exists changes that row. Only the fields you give change: {"id": "van", "hearts": 8} gives the Armored Van eight hearts.
  • Any other row is new, and its id must start with your mod id and a dot, as in nightmare.nightmare. This keeps two mods from claiming the same id.
  • Mods load after the base game, in folder-name order. A later mod's change wins. New difficulties join the top of the ladder in that order.
  • Every row is checked before the game uses it. Checks cover unknown fields, values out of range, non-ASCII text, missing prefixes, and references to things that do not exist. One bad row skips the whole mod, with the reason in the MODS list. The game always starts on what is valid.

Enemies and bosses

An enemy is a row in enemies.json built on one of the game's families. It keeps that family's driving, attacks, collisions and takedown rules, and the row sets its numbers, look and spawn schedule.

base Family Arrives
patrol, attack_bike, gang Cop car, flanking bike, ramming gang car On a road ahead
helicopter Police helicopter Off a screen corner
officer, soldier On foot On a road just past the screen edge
tank Army tank Where the game's tanks arrive
[{"id": "warlord.rifleman", "base": "soldier", "label": "RIFLEMAN", "hp": 10, "gold": 15,
  "spawn": {"heat": 3, "every": 30, "count": 3, "max_alive": 6}}]
  • A weapon adds aimed police rounds on top of the family's own attack.
  • Helicopters and tanks keep their family's speed.
  • A behavior (a code mod's game.enemy) can steer the enemy, change the damage it takes and act when it is destroyed.

A row with boss is a boss fight. It gets the game's boss bar under the wanted level, a mark on the minimap, the camera lean and blasts when it falls, and, with music, its own looping track:

{"id": "warlord.warlord", "base": "tank", "label": "WARLORD", "hp": 400, "gold": 600,
 "spawn": {"heat": 4, "every": 120},
 "boss": {"title": "THE WARLORD", "intro": "WARLORD INBOUND", "music": "mixtape_brute_force",
  "phases": [{"at": 0.6, "weapon": {"every": 1.2, "range": 240, "lanes": 3, "spread": 14}},
             {"at": 0.25, "weapon": {"every": 0.6, "range": 260, "lanes": 5, "spread": 12}}]},
 "behavior": "warlord.warlord"}
  • A boss arrives alone and once per run. It never arrives while another boss is up, or once the map's own boss or escape has begun.
  • It is never cleared away off screen.
  • Each phase starts when its share of health is left. It can change the boss's weapon and speed, and it calls the behaviour's phase(s, e, n). The warlord example calls in riflemen at each phase.
  • Any track can be boss music. A music row with "boss": true stays out of the shuffle and plays only in its fight.

Safehouse and contracts

Between runs, players spend banked cash on Safehouse lines and earn rewards through contracts. Both are content, so a mod can add to either.

A Safehouse line in safehouse.json raises run stats per level. The stats are listed in Safehouse stats:

[{"id": "neon.war_chest", "label": "WAR CHEST", "line": "+$40 AND 2% REACH PER LEVEL",
  "max": 5, "base": 700, "effects": {"start_cash": 40, "magnet": 0.02}}]
  • Prices start at base and grow 30% a level, or by the line's growth.
  • A line may name a game.effect instead of, or as well as, effects. It runs in every run once the line has a level, with the level as its rank. The scrapyard example's JUNK MAGNET refills NOS on every wreck.
  • A line with "lockbox": true goes on sale only once a lockbox gives it. It needs a lockbox.json row with "unlocks": "safehouse" and "type": "SAFEHOUSE", as the warlord example's WAR BONDS has.
  • A mod can change the game's lines too: {"id": "seed_money", "max": 14} adds two levels.

A contract is a reward row (a car, part, map unlock or paint) with a counter and a target. A reward can count one of the game's own counters, or one the mod adds in counters.json and feeds from code:

[{"id": "warlord.warlords_wrecked", "label": "WARLORDS WRECKED"}]
death = function(s, e) game.count(s, 'warlord.warlords_wrecked') end,
  • The reward's scope decides whether the count adds up across runs (total) or must be reached in one run (run).
  • Contract progress reads as the counter's label, the numbers and its unit, such as WARLORDS WRECKED 1/3.
  • When a mod is removed, or fails to load after an update, the save keeps its counters, Safehouse levels, lockbox finds and unlocks. They come back with the mod.

Announcer

The announcer is the card in the top left, with a face and one or two short lines. Every line is a row in announcer.json, and the game calls its own lines on its events. Announcer lists them all and when each plays.

A mod can re-voice a game line. It can give the line new words, a face of its own from sprites/ or a sound from sfx/, such as recorded speech:

[{"id": "helicopter", "lines": ["Chopper overhead."], "portrait": "neon.boss", "sound": "neon.chopper"}]
  • A face is the sprite boss_idle.png, which becomes neon.boss_idle. Add boss_talk_a.png and boss_talk_b.png and the mouth moves while it talks.
  • A line with enemy introduces that enemy the first time one comes close. The enemy can be one of the game's or a mod's enemies row. The warlord example's boss introduces itself this way, with a face and a radio sound.
  • priority, cooldown and ttl decide which call goes first and how long it waits.
  • Code calls any line with game.say, and can pass live text to show instead:
game.say(s, 'scrapyard.truck_down', {'Scrap truck down.', n .. ' wrecked this run.'})

A mod made only of lines, faces and sounds is cosmetic, like a paint mod.

Maps

Each map is a row in maps.json. A row can change a game map's name, sky, street colours and traffic, and a City map's boss and lines. A mod's new row adds a map built on a game map (base). It plays on that map's island and streets, with its landmark and mechanics, and joins the map dial with an unlock of its own:

[{"id": "neon.night", "base": "city", "label": "NEON NIGHT",
  "group": "SURVIVAL", "counter": "escapes", "target": 1, "full": true, "requirement": "Escape once",
  "boss": "neon.lineman", "final": "neon.final", "escapes": "neon.escapes",
  "traffic": 1, "colors": {"road": [14, 15, 22]},
  "sky": {"keys": [{"at": 0, "ambient": [0.4, 0.44, 0.6], "lamp": 0.5}, {"at": 1, "lamp": 0.05}]}}]
  • sky is the light over the run, as keyframes from the start (0) to the finale (1). The City has no sky, so a City map without one keeps its night.
  • colors recolours the streets, kerbs, lane paint and car parks over the base map's tileset.
  • boss replaces the Collector on City maps. It names an enemies row with boss, which arrives after the five-star minute and keeps its bar and music. The freeway escape opens once it falls, and none opens it at once. The other maps keep their bosses, which lead into finales of their own.
  • final names the announcer line at five stars. escapes names a mod counter that each escape from the map adds 1 to, so a reward can name it.
  • An enemies row with maps spawns only on those maps.
  • Code reads the run's map with game.map(s).
  • The map dial shows a new map with its base map's preview, or with preview, a 208 by 144 sprite of the mod's.

The blackout example is a power cut on the City's streets: dark roads, lamps that fade as the heat rises, cable-cutter bikes, and the Lineman as its boss. Escaping it once earns a paint.

A new island

A City map can lay an island of its own. The City's generator builds its streets, blocks and districts to the row's plan:

{"id": "atoll.atoll", "base": "city", "label": "SMUGGLER'S ATOLL",
 "island": {"width": 5, "height": 6},
 "land": {"airport": 0, "railyard": 0, "suburbs": 0, "slum": 9, "luxury": 6},
 "features": {"canal": false, "rails": false, "dealerships": false},
 "landmark": "none", "mechanics": "atoll.seaplane", "preview": "atoll.preview"}
  • island is its size in districts, 3 to 12 wide and 4 to 12 high. The run starts in the middle, and the freeway escape needs at least 4 rows.
  • land sets how many districts are each kind. Kinds it leaves out keep the City's share, and the four central districts are always city blocks.
  • features switches off the canal, the railway, the barrel yards, the overhead wires or the used-car lots.
  • landmark is revenue_hq (the City's) or none.
  • To paint a preview from the real layout, run python3 scripts/generate_map_previews.py --mod path/to/mymod. It writes sprites/preview.png (needs Pillow and LuaJIT).

Map code

mechanics names code registered with game.mechanics. Its events run only on that map's runs, and four handlers change the map's own rules:

game.mechanics('atoll.seaplane', {
  finale = function(s)          -- each tick after the five-star minute
    if not s.atoll_plane then     -- the dock: a street near the north-east corner
      local _, top, right = game.island(s)
      local x, y = game.street(s, right - 600, top + 600)
      s.atoll_plane = {x = x, y = y}
    end
    if game.distance(s.player, s.atoll_plane) < 140 then
      game.win(s, 'THE SEAPLANE FLEW YOU OUT.')
    end
    return true                   -- true: the Collector does not come
  end,
  objective = function(s) return 'REACH THE SEAPLANE' end,
  route = function(s)
    if s.atoll_plane then return s.atoll_plane.x, s.atoll_plane.y end
  end,
})
  • finale holds the endgame while it returns true. Once it returns nothing, the map's boss, the Collector or the freeway escape follows.
  • objective replaces the objective pill's text, with a second line in the objective panel.
  • route guides the player along the streets to a place.
  • damage changes the damage the player deals to an enemy.
  • game.win(s, reason) ends the run as an escape. game.island(s) gives the island's bounds and game.street(s, x, y) the nearest street point.

The atoll example is a small island of slums and villas with no canal, no trains and no used-car lots. After the five-star minute a seaplane waits at the north-east dock for 90 seconds, and missing it brings the Collector. Its main.lua adds the timer and a gold ring at the dock.

Translations

A language is a row in languages.json, and its words are text/<id>.json in the same mod. The id is a language code such as fr or pt_BR, and the name is what the language list shows:

[{"id": "fr", "name": "FRANÇAIS"}]

The text file maps keys to text. Every key it leaves out reads in English, so a translation can start small and grow:

{"settings.volume": "VOLUME PRINCIPAL",
 "difficulties.standard.name": "TROP PETIT POUR LE FISC",
 "objective.beat": "BATTEZ {boss}"}
  • The game's own interface text is in game/data/content/base/text/en.json, by key: the menus, the HUD, results, the garage, the tutorial, run objectives and building signs. Key names, the title logo and the maps' place names, such as street names, stay as they are.
  • Any row's text has a key too: <kind>.<id>.<field>, such as cards.machine_gun.name, for the game's rows and a mod's. Translations lists the fields.
  • A translation keeps the English text's {tokens}, which the game fills in.
  • The fonts draw ASCII, Latin-1, Latin Extended-A and Vietnamese. A mod with a letter they cannot draw is turned down with that letter named.
  • A mod's own text goes in its text/en.json under its id, such as neon.hello, with any translations beside it. Code reads it with game.text('neon.hello', {name = 'Anna'}).

Players choose the language under Settings > ACCESSIBILITY > LANGUAGE, and it applies the next time the game starts. The francais example translates the whole interface, plus the difficulty ladder, the run modifiers, the map names and the announcer's first line.

Code mods in one page

-- mods/scrapyard/main.lua  (mod.json: "scripts": ["main.lua"])

-- Content, exactly like the JSON files:
game.add('enemies', {id = 'scrapyard.truck', base = 'gang', label = 'SCRAP TRUCK',
  hp = 40, gold = 60, spawn = {heat = 3, every = 25}, behavior = 'scrapyard.truck'})

-- Behaviour the rows name:
game.enemy('scrapyard.truck', {
  think = function(s, e, dt)
    if game.distance(e, s.player) < 140 then
      game.drive(s, e, s.player.x, s.player.y, 480, dt)
      return true                       -- replaces the base driving this tick
    end
  end,
  death = function(s, e) game.blast(s, e.x, e.y, 48, 200) end,
})

-- A difficulty with a rule only code can express:
game.effect('scrapyard.leaky_tank', {tick = function(s, dt) game.nos(s, -0.02 * dt) end})
game.add('difficulties', {id = 'scrapyard.leaky', numeral = 'L', name = 'LEAKY TANK',
  short = 'LEAKY NOS', rule = 'YOUR NOS TANK SLOWLY DRAINS', payout = 1.5,
  effect = 'scrapyard.leaky_tank'})
  • Call game.add and game.change at the top level of a script. Rows are checked when the mod finishes loading.
  • Handlers get the run state s first. The reference lists the fields that stay stable.
  • Use game.random rather than math.random. It has its own seeded stream per run, so modded runs stay reproducible and never change what the game rolls.
  • A handler that errors is switched off for the session and reported. The run continues. A script that fails while loading skips its whole mod.
  • Require game modules (such as game.upgrades or game.vehicles) inside handlers, not at the top of a script. Content is still loading while scripts run.

Drawing and the HUD

Code mods can draw. Three events run once per displayed frame, each with a drawing kit d:

  • draw_ground: on the streets, under cars and pickups.
  • draw_world: over cars, rounds and aircraft.
  • draw_hud: over the game's HUD, in the 640 by 360 HUD space.
-- A ring on every enemy near the car.
game.on('draw_world', function(s, d)
  for _, e in ipairs(game.enemies(s, s.player.x, s.player.y, 200)) do
    if d.visible(e.x, e.y, 16) then d.ring(e.x, e.y, 14, 'red', 0.6) end
  end
end)

Content enemies' draw handlers and code weapons' draw handlers get the same kit, so a weapon can show its reach and an enemy can wear a marker.

  • Draw handlers only draw. Frames come as fast as the player's screen allows, so anything a draw handler changed would make runs depend on the machine. Work numbers out in tick or another event, keep them in s, and read them when drawing. Calls that change the run, such as game.cash, game.spawn and game.random, raise an error from a draw handler, which switches the mod off.
  • Animate with d.time. Skip flashing or moving effects when d.reduced_flashes or d.reduced_motion is true.
  • Colours are names such as 'gold' and 'red', or {r, g, b}. Sprites are the game's own or your mod's (modid.name).
  • On the HUD, d.anchor picks the screen edge your drawing stays attached to as the player changes UI SCALE.
  • Each layer draws up to 2,000 kit calls a frame, and calls past that are dropped, so one mistake cannot stall the game.
  • The reference lists every kit function.

Mod settings

A code mod can give players options. List them in mod.json settings. They appear on the mod's own page in Settings > CREDITS, are saved with the player's settings, and scripts read them with game.setting(id).

"settings": [
  {"id": "panel", "label": "RUN PANEL", "type": "toggle", "default": true},
  {"id": "side", "label": "PANEL SIDE", "type": "choice", "values": ["LEFT", "RIGHT"], "default": "LEFT"},
  {"id": "opacity", "label": "PANEL OPACITY", "type": "slider", "min": 0.2, "max": 1, "step": 0.1, "default": 0.8}
]
  • A setting is a toggle, a slider or a choice, up to 16 per mod. The reference lists every field.
  • Read a setting where you use it. Players can change it from the pause menu in the middle of a run.
  • A saved value that no longer fits, for example after an update changes a slider's range, reads as the default. Choices are kept while a mod is removed, so reinstalling it keeps them.

The dashboard example does both: a run panel and a marker on the nearest enemy, with settings to move, fade or hide them. The scrapyard example shows its cannon's reach.

Publishing to the Steam Workshop

A Workshop item holds one file, mod.zip, with your mod's files at its top. The uploader makes it and writes the script SteamCMD uploads:

python3 scripts/workshop_publish.py path/to/mymod
steamcmd +login YOUR_LOGIN +workshop_build_item dist/workshop/mymod/item.vdf +quit

--upload --user YOUR_LOGIN runs SteamCMD for you.

  • Checked first. The uploader loads the mod the way the game does and stops with the reason if the game would skip it. To check a mod without publishing it, run luajit ../scripts/mod_check.lua path/to/mymod from game/.
  • Preview. Put preview.png, preview.jpg or preview.gif in the mod folder, under 1 MB. It becomes the item's image and stays out of mod.zip.
  • First upload. The item starts private, so you can check its page first. Make it public there, or pass --visibility public. Its title and description come from mod.json.
  • Updates. The new item's id goes into mod.json as workshop. Run the uploader again after the first upload, or let --upload do it. Later uploads update the same item and leave its page alone. --note sets the change note, and --page copies the title and description again.
  • Size. The game reads archives up to 256 MB.

What the API promises

mod.json api names the API version a mod was made for, and this game reads 1. Within a version, the game functions, events, run-state fields and content fields in the reference are only ever added to. A breaking change bumps the version, and mods made for another version are skipped with a clear reason.

Scripts can still require any game module. That works, but anything not in the reference may change in any update.

What is still game code

  • Maps: a new island is laid out by the City's generator, so street plans of another kind, and the other maps' islands and landmarks, are still game code. Only City maps can change their island or boss, and map code cannot hook into the generator itself.
  • Enemy families: content enemies build on seven families. The others (gunships, jets, UFOs, troop drops and the Collector) are still game code.
  • Bosses: a mod boss is one fight at a time and one per run, or a City map's own fight. The game maps' own bosses are still game code.
  • Counters: the game feeds its own counters from game code. A mod's counters are fed only by its scripts, through game.count.
  • Announcer: when the game's own lines play is game code. A mod changes their words, face, sound and timing, and adds lines for its enemies and its code.
  • Text: the interface and every content row's text have keys. Key names, the title logo and the maps' place names do not, and the fonts draw only Latin letters, Vietnamese included.
  • Drawing: mods draw in three layers on top of the game's own drawing. They cannot hide or reorder what the game draws, except by replacing sprites.
  • Steam Workshop: subscriptions load when the game starts, not while it runs, and publishing is the uploader script, not a button in the game.

For developers: opening up a system

Each system is opened the same way, which keeps the API and the docs consistent:

  1. Data first. Move the system's numbers and lists into a content kind:
  2. Add it to S.KINDS and S.fields in game/content_schema.lua.
  3. Add a doc entry for every field to S.DOCS.
  4. Add the base file to game/data/content/base/.
  5. List that file in scripts/release_manifest.json.
  6. Have the game read it through Content.list(kind).

Base rows keep their order and values, so level digests do not change. game.brackets is the model: the ladder is difficulties.json, and the code only reads the row. 2. Hooks for behaviour. Where a row needs to choose code, give it a field of type hook that names an entry in game.hooks. Base behaviour registers there too, as game.car_perks does. With nothing registered, the simulation skips the dispatch. 3. API entries. Add helpers modders need to game/game/mod_api.lua. Each entry carries its doc and example. 4. Regenerate the reference. From game/, run lua ../scripts/mod_api_docs.lua. mod_api_docs_test.lua fails if a field or entry has no doc, or if API.md is stale. 5. Add an example in docs/modding/examples/. mod_examples_test.lua loads every example.

Player-facing text goes through game/game/text.lua. Give the English a key in game/data/content/base/text/en.json and draw T('menu.key'), with {name} tokens for live values. A content field marked text=true in the schema is translatable by its row key with no other change.

Tuning numbers stay in their modules, next to their comments. To make one moddable, list it in a system in game/game/balance.lua with a range and a doc. Then make sure the module calls Balance.apply on its table before it derives anything from it, as game.economy and game.pursuit_balance do. The reference shows the default it records.

Mod art and sound load through game/game/mod_assets.lua: - Positions are decided while content loads. - main.lua hands the list to Graphics.addSprites and Sound.register before the graphics load. - Graphics.composeSprites pastes the art into the atlas. - CPU previews draw from data/sprite_atlas.json, so they do not show mod art.

Run python3 scripts/dev.py test content_mods code_mods mod_api_docs mod_examples mod_assets balance_mods modifiers weapon_mods mod_draw enemy_families meta_mods announcer_mods maps_mods island_maps translations workshop_mods to test the mod systems, and python3 scripts/workshop_publish_test.py for the Workshop uploader.

Workshop items reach the loader through two platform modules. game/platform/steam.lua is the only one that loads LuaSteam. It does nothing when Steam or the library is missing. game/platform/workshop.lua mounts each installed item's mod.zip at workshop/<item id>, so game.content reads it like a folder in mods/. Tests give it a fake LuaSteam with Steam.use(adapter) before Steam.start().