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
gamefunction, 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¶
- Make a folder
mods/nightmare/. - Add
mod.json:
- 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}]
- 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
_paintmask that takes the chosen paint, and an_invulnerableframe. Name themcab.png,cab_paint.pngandcab_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
_paintand_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_atgives 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"]}
]
payoutmultiplies 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,cashandcrowdmultiply. - For a rule the fields cannot express, give an
effect. It is agame.effectthat 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.
upgradessays what each of the weapon's own cards adds per rank. Give those cardsrequiresthe 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'shuntertrait:
{"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
idalready 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
weaponadds aimed police rounds on top of the family's own attack. - Helicopters and tanks keep their family's speed.
- A
behavior(a code mod'sgame.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
weaponandspeed, and it calls the behaviour'sphase(s, e, n). The warlord example calls in riflemen at each phase. - Any track can be boss music. A music row with
"boss": truestays 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
baseand grow 30% a level, or by the line'sgrowth. - A line may name a
game.effectinstead 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": truegoes on sale only once a lockbox gives it. It needs alockbox.jsonrow 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:
- The reward's
scopedecides 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 itsunit, such asWARLORDS 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 becomesneon.boss_idle. Addboss_talk_a.pngandboss_talk_b.pngand the mouth moves while it talks. - A line with
enemyintroduces that enemy the first time one comes close. The enemy can be one of the game's or a mod'senemiesrow. The warlord example's boss introduces itself this way, with a face and a radio sound. priority,cooldownandttldecide which call goes first and how long it waits.- Code calls any line with
game.say, and can pass live text to show instead:
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}]}}]
skyis 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.colorsrecolours the streets, kerbs, lane paint and car parks over the base map's tileset.bossreplaces the Collector on City maps. It names anenemiesrow withboss, which arrives after the five-star minute and keeps its bar and music. The freeway escape opens once it falls, andnoneopens it at once. The other maps keep their bosses, which lead into finales of their own.finalnames the announcer line at five stars.escapesnames a mod counter that each escape from the map adds 1 to, so a reward can name it.- An
enemiesrow withmapsspawns 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"}
islandis 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.landsets how many districts are each kind. Kinds it leaves out keep the City's share, and the four central districts are always city blocks.featuresswitches off the canal, the railway, the barrel yards, the overhead wires or the used-car lots.landmarkisrevenue_hq(the City's) ornone.- To paint a preview from the real layout, run
python3 scripts/generate_map_previews.py --mod path/to/mymod. It writessprites/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,
})
finaleholds the endgame while it returns true. Once it returns nothing, the map'sboss, the Collector or the freeway escape follows.objectivereplaces the objective pill's text, with a second line in the objective panel.routeguides the player along the streets to a place.damagechanges 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 andgame.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:
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 ascards.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.jsonunder its id, such asneon.hello, with any translations beside it. Code reads it withgame.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.addandgame.changeat the top level of a script. Rows are checked when the mod finishes loading. - Handlers get the run state
sfirst. The reference lists the fields that stay stable. - Use
game.randomrather thanmath.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.upgradesorgame.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
tickor another event, keep them ins, and read them when drawing. Calls that change the run, such asgame.cash,game.spawnandgame.random, raise an error from a draw handler, which switches the mod off. - Animate with
d.time. Skip flashing or moving effects whend.reduced_flashesord.reduced_motionis 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.anchorpicks 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/mymodfromgame/. - Preview. Put
preview.png,preview.jpgorpreview.gifin the mod folder, under 1 MB. It becomes the item's image and stays out ofmod.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 frommod.json. - Updates. The new item's id goes into
mod.jsonasworkshop. Run the uploader again after the first upload, or let--uploaddo it. Later uploads update the same item and leave its page alone.--notesets the change note, and--pagecopies 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:
- Data first. Move the system's numbers and lists into a content kind:
- Add it to
S.KINDSandS.fieldsingame/content_schema.lua. - Add a doc entry for every field to
S.DOCS. - Add the base file to
game/data/content/base/. - List that file in
scripts/release_manifest.json. - 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().