Using mods
Where the mods folder is
Open Mods from the main menu. The path is printed at the top of the screen, with a button beside it that opens the folder in your file browser.
It sits next to your saves, and it is the same folder whether you are installing somebody's mod or writing your own. Nothing is read while the game is running, so it does not matter whether the game is open while you move files around.
Installing one
A mod is a folder with mod.json at the top of it. Most downloads are that folder in a zip.
| 1. Unzip it | So that the folder holding mod.json sits directly in the mods folder. A zip that unpacks to northern_reach/northern_reach/mod.json is one level too deep and the game will not see it. |
| 2. Restart the game | Packages are read at start-up. There is no live install. |
| 3. Switch it on | Open Mods and use the switch. A copied folder is found and left off — nothing loads because it appeared in a folder. |
| 4. Restart again | The row says On — restart to load until you do. Then it says Loaded. |
A mod you copied in is off until you say otherwise. The game refuses executables outright, but a data package can still change prices, items and people in ways you did not ask for, and one that turned up by itself inside a save in progress is a problem nobody wants to diagnose. So the game asks. It is one extra restart the first time, and never again for that package.
There is no launcher, no load-order file to edit and nothing to register. Load order is a number inside each package, chosen by its author.
Start a new run for a package that adds people, places or quests. A save already in progress keeps the world it was made with. Packages that only change numbers, pictures or colour are visible in a save you are already in. Audio and font files are validated, but no running package surface consumes them yet.
Did it load?
Mods, in the main menu. Every package it found is on one row, with a state in a word: loaded, off, not valid, in conflict, needs another mod, skipped.
A mod can also switch itself off, and it says why. The switch you set applies to the package you set it on, not to its name in the folder. Mod ids are chosen by their authors and nothing coordinates them, so two unrelated mods called northern_reach is an ordinary accident — and without this the second would inherit the first one's switch. If the mod under an id is not the one you turned on, the row says so: this is not the mod you turned on. Updating a mod in place keeps your switch; deleting it and copying a different one does not.
If you are a mod author and you see that line about your own package, you have an id collision with somebody else's mod. Pick a less obvious id.
Two of those words are about a restart you owe rather than about the package. On — restart to load means you switched it on and nothing has read it yet; Off — restart to unload means the opposite, and its content is still in the game until you restart. They are separate words on purpose: a screen that said "Loaded" the moment you flipped a switch would send you looking in the world for something that is not there yet.
When the state is not "loaded", the line under the row says why, in the language you are playing in and without an identifier you did not type — which file the game refuses to run, which package is missing, which version it wants. Under that, what the package's content did: how many entries went in, how many were refused. Show details opens the rest, which is the ids and the exact rule each refused record broke.
| On the screen | In the report | What it means |
|---|---|---|
| Loaded | active | Loadable, no blockers. |
| Not valid | invalid | Manifest carries blocked script/exe files. |
| Needs another mod | skipped_missing_dependency | A declared dependency is absent or disabled. |
| Needs another mod | skipped_incompatible_dependency | A declared dependency is outside its range. |
| Skipped | incompatible | The mod targets a newer game build. |
| In conflict | conflict | Two loadable mods target the same override id (still loadable; load_order wins, both flagged). |
| Off | disabled | You switched it off, or you have never switched it on. Packages are opt-in: dropping one in the folder installs it and does not turn it on. |
| On — restart to load | — | You switched it on and nothing has read it yet. Its content is not in the game until you restart. |
| Off — restart to unload | — | You switched it off and it is still loaded. Its content stays in the game until you restart. |
Two rows share a word on purpose: Needs another mod is the same problem to a player whether the package it needs is missing or is the wrong version, and the line under the row says which. And when two packages claim the same record, the higher load_order writes last and wins — but a takeover nobody declared is an error, not a note. Both rows are flagged and the loser's row names the winner, and the winner is still worked out rather than left to chance: a refusal must not also be a coin flip. When two mods want the same thing is how to declare one on purpose.
"Loaded" does not yet mean you can see it in the world. For that, read the counts under the row, and Now find it in the game says where each kind of thing turns up.
The counts always add up. Read is applied, plus rejected, plus replaced by another mod, plus waiting for its conditions — and the last two are named rather than left as a gap. A record another package writes last is not refused, because nothing is wrong with it, and not applied either, because it is not the one the game kept. It is the difference between reading one line and spending an evening looking for a bug in a file that does not have one. Show details says which record, and which package has it now.
One number sits beside that sum rather than inside it: files nobody read. A file whose family is not in content_types was never opened, so there is no record to account for and no way to know how many are in it. It is a count of files, not of records, which is exactly why it cannot be a sixth term: the sum starts at read, and these were never read.
Before you ask anyone for help
Open Mods and press Copy report. Paste the whole thing into your message, then say what you expected.
One plain text, ready for a forum, a Discord message or a bug report: the build you are on, the mods folder, every package with its version and load order and state, the reason behind every refusal, the counts for every kind of content, and which package won every clash.
the report the Mods screen copies
Bonemark mod report
game build: 0.0.0-dev
mods folder: C:/Users/you/AppData/Roaming/Godot/app_userdata/Bonemark/mods
packages: 2 found, 1 loaded
PACKAGES
northern_reach v0.1.0 order 100 active
goods: 12 read, 12 applied, 0 rejected
winter_roads unreadable manifest
- game_version must not be empty
CONTENT (1 families: 12 read, 12 applied, 0 rejected)
goods: 12 read, 12 applied, 0 rejectedNine times in ten the answer is in the block and nobody has to ask you a single question first. It reads nothing but the mod folder, changes nothing, and contains no personal information.
It is in English even when you are playing in another language. That is on purpose: whoever helps you may not read yours, and every fact in it — ids, versions, counts — is the same in every language anyway.
Taking one out again
Delete its folder and restart. That is the whole uninstall.
Nothing of yours is deleted with it. A save keeps ids it can no longer resolve, reports them, and hands the things back if the package is reinstalled. A session that cannot understand a save does not overwrite it either — the original file is left exactly as it was, which is the difference between an afternoon lost and a run lost. Saves is the full account.
If the game will not start at all after installing something, the way out does not need the game. Open the mods folder and move the last thing you added out of it. Packages are only read from that folder, so a folder that is not there is a package that cannot be loaded, and the next start is the start you had before. You do not need a menu, a console or a working launch to do it.
The game also tries by itself — If the game will not start is a bounded number of retries and then a way back in with no packages loaded — but that is the belt, and moving the folder is the braces.
Start here
What you can build today
Now, without asking anyone: goods and the merchants who sell them, items, people and where they stand, buildings and the services inside them, places on the map, conversations, scenes, quest plans, maps drawn in Tiled, pictures and colour, and the words for all of it in seven languages. Audio and font files are checked and read, but their package surfaces are not consumed yet; the status table says this plainly instead of calling them ready. Every family is the measured list, and its status column comes out of the build rather than out of a promise.
Not yet, and What you cannot do yet, and why says why for each: the quests family, package audio, package fonts, packaged total conversions, and a package that is a feature rather than content.
Never, by design: code. No scripts, no scenes, no resource files, nothing the game would have to execute. That is what makes a stranger's mod safe to download, and it is not a stage the project is passing through. What a mod can never do.
Time to the first thing you can see in the game: about ten minutes, most of it spent finding the folder. A mod in ten minutes is the whole path, and the sword at the end of it is real.
What you cannot do yet, and why
Written here, near the front, rather than left to be discovered on the way out. These are the things the build validates without currently putting them on a player-facing package surface, plus the things that are not package content at all.
| What | Why not | Will it change |
|---|---|---|
The quests family | It is recognised and checked and there is no runtime mapper behind it, so a package targeting it passes every check and installs nothing. Write Quests of your own instead: quest_plans is a live family and it is the one the game actually reads. | Yes, when the mapper is public |
| Package audio | Audio descriptors and files are validated and read, but no running surface consumes a package cue yet, so a mod can ship sound that remains silent. | Yes |
| Package fonts | Font descriptors and locale coverage are validated, but no running theme consumes a package font yet. | Yes |
| A packaged total conversion | The build understands the manifest and ships the Ashen Reach fixture. What is missing is the packaged creator workflow around it, not the machinery. A total conversion. | Yes |
| A package that is a feature | Settled in full and no family carries it, so no manifest can ask for one. Every family lists it under what has no folder yet. | Yes |
| Anything that runs | A scripting tier is not coming. The manifest carries a package type that ships behaviour, the only thing that could authorise running it refuses it, there is no sandbox behind it, and a package declaring it is refused today. What a mod can never do. | No |
A mechanic the game has never had is the fourth row wearing a different hat. A sword that hits harder is a number and it is yours; a weapon that fires a beam needs beam rules, and rules are code somebody writes and tests.
What a mod is
A folder of text files. You do not write code. You write lists of things you want in the game, and the game reads them when it starts.
| Word | What it means |
|---|---|
| Package | Your mod. One folder with your name on it. |
| Manifest | mod.json, at the top of that folder. Who you are, what you bring. |
| Record | One thing you add. One sword is one record. |
| Family | A kind of thing. Swords, helmets and bread are all items. Each family has its own folder. |
| Id | your_package:the_thing — for example northern_reach:frost_blade. |
The one rule everything else follows: you may add and change data. You may not make the game run code. That is what makes a mod safe to download — the worst a stranger's mod can do is put a silly sword in your game.
34 families are ready to use today, among them items, buildings, pois, npc_definitions, vendor_inventories, quest_plans, caravans and perks. Every family is the whole table with what each one is; The families, one by one gives every one of them its fields and a record you can copy.
Pick your path
This is the index of the whole guide — where a subject is written up, whether or not you are building it today. There is a second, shorter index at the top of Part 3: that one is the six tutorials, in the order they build on each other. If you are here to make something rather than to look something up, go straight to it.
| You want to | Go to |
|---|---|
| Build something start to finish | Start from what you want to make — the six tutorials, one package |
| Get one file loading in ten minutes | A mod in ten minutes |
| Look up one family's fields and an example | The families, one by one |
| See everything you are allowed to make | Every family |
| Change something the game already ships | Change what the game already has |
| Add your own art, sound, lettering or colour | Pictures, sound, lettering, colour |
| Draw a place in Tiled | Maps of your own |
| Write a quest | Quests of your own |
| Work out why nothing showed up | Nothing showed up |
| Look up an error code | Every diagnostic code |
| Know what a mod can never do | What a mod can never do |
| Not break somebody's save | Saves |
| Put it where people can get it | Sharing it |
Or by the thing you have in your head. This guide is arranged by the order the work happens in — manifest, then validation, then content, then reports — which is the order somebody who already knows the system would choose. Somebody who does not is thinking I want to add a merchant, and looking for the word merchant:
| You are adding | The families that do it | Where they are written up |
|---|---|---|
| Trade and goods | goods, vendor_inventories, caravans, recipes | Things, trade and the numbers behind a fight |
| Things a player carries | items, combat_balance | Things, trade and the numbers behind a fight |
| People | npc_definitions, mercenary_templates, noble_houses, relationships, perks, enemy_archetypes, enemy_variants, group_archetypes | People, houses and what they can do |
| Places and buildings | buildings, pois, maps, world_generation | The world and the places in it |
| Words a player reads | locales, dialogue, scenes, lore, mr_lines, quest_plans, quest_metadata, progress | Story, quests and the words a player reads |
| How it looks and sounds | images, audio, fonts, themes, effects, presentation_bindings, screens | How it all looks and sounds |
| Controls and difficulty | input_bindings, settings, adaptive | What the player controls, and what watches them |
What you need
| The game | Required | No Godot, no compiler, no build step. |
| A JSON-aware text editor | Required | VS Code, Sublime, Notepad++ — all free. Windows Notepad works and will not help you. |
| A pixel-art editor | Only for your own art | Aseprite (~20 €), or free: LibreSprite, Piskel, Krita, GIMP. |
| An audio editor | Only for your own sound | Audacity. Short effects from nothing: sfxr, ChipTone. |
| Tiled | Only for your own maps | Free. Export as .tmj. |
An editor that colours JSON shows you a missing comma while you are still looking at it, instead of the game telling you later that the file was unreadable.
Python is optional, and the console is optional. The game itself is enough: drop the package in, restart, and open Mods, which names every record it refused and why. The author checkout is public. Authors who want faster iteration or release automation can clone the public Bonemark source checkout and run the offline tools in Offline tools; players installing a released package do not need Python or Godot. The same public checkout is the source of the validator and the runtime's declarations, so the two sections below describe one toolchain. The tools use the same declarations as the game — the record schemas and the safety boundary are one source each — so they tell you sooner, never differently. A debug console is useful for inspecting a loaded package, but it is not required to write one.
Make one
Start from what you want to make
Everything below this line is arranged the way the work happens — manifest, then files, then families, then reports. That is the right order for looking something up and the wrong order for starting, because nobody starts with a manifest. They start with I want a merchant.
So: pick the sentence that sounds like what you are trying to do.
| You want to | Go to | How long |
|---|---|---|
| Add something to buy and sell | A good to sell | 15 minutes |
| Add something a player carries | An item to carry | 10 minutes, after the one above |
| Add a person who sells it | A merchant who stocks it | 30 minutes |
| Add a door in a settlement | A building service | 20 minutes |
| Add a place on the world map | A place on the map | 20 minutes |
| Change something the game already has | Changing what is already there | 15 minutes |
| Draw a map, write a quest, ship art | Maps of your own, Quests of your own, Pictures, sound, lettering, colour | longer, and all three are live families |
Ship the quests family, a packaged total conversion, or a package that is a feature | Not yet. What you cannot do yet, and why gives the reason for each and says which of them will change. | — |
| Add a rule the game has never had | Nothing here does that, and nothing will. What a mod can never do says why, in one paragraph. | — |
The six tutorials are one package, built up a piece at a time: a beeswax candle, the bundle a player carries, the chandler who sells it, her shop door, the pit behind the tannery where the tallow comes from, and finally a change to the candle the game already had. Do them in order and you finish with a mod somebody could download. Do one out of the middle and it works on its own.
Where the game's own numbers are
Every tutorial here prices a new thing against something the game already sells, because a number with nothing beside it is a guess, and a guess in an economy that trades between towns does not stay where you put it.
You cannot open the game's own files. Its records are Godot resources inside the packaged build — a different format from the JSON you write, with category = 1 where your file says "MANUFACTURED". There is no folder to copy from, and unpacking one is not a workflow anybody should document.
You do not need to. The catalogue publishes the game's own content, generated from those same records on every build: 270 items with their category, weight, value in coin and stack size, 49 weapons, 42 pieces of armour, 37 places. It is the reference this guide anchors to, it cannot drift from the game, and the field name is printed beside every row.
A dozen values worth knowing before you price anything:
| The game already sells | Coin | And |
|---|---|---|
| Clay, stone, reeds | 1 | the floor. Dug or cut, no work in it |
| Grain, wood | 2 | |
| Candle | 3 | one evening of light |
| Wool, fish, grapes | 4 | |
| Bread | 6 | a day's food |
| Salt, honey, rope | 8 | |
| Cloth (bolt) | 10 | woven from 2 wool and 1 reeds |
| Refined iron, salted fish | 12 | |
| Black pepper | 14 | an import, and it tastes like one |
| Leather, cheese | 15 | |
| Wine | 25 | |
| One-handed weapons | 50–300 | the band, not a single value |
| Two-handed weapons | 150–600 | |
| Heavy plate | 500–1500 | the ceiling of the whole economy |
If your new thing costs more than plate armour, something has gone wrong and the game will not tell you: it will quietly become the most profitable cargo in the world and every caravan will carry it.
A good to sell
You will have a trade good the economy knows how to price, anchored to the candle the game already sells, ready for a merchant to stock.
Screenshot: not taken yet. This slot stays empty until there is a real one — a drawing of what it might look like would be the only untrue thing on the page.
Read first: What a mod is · A mod in ten minutes · Names and ids
Copy from: the folder called chandlery, which is already in your mods folder. The game puts it there the first time it starts, switched off, and it is the finished result of these six tutorials — every record below is in it. It is also the package the repository's own test lane runs against the loader, so nothing here is a record the game would refuse. Turn it on to see it working, or copy the folder, rename it, and make it yours.
Where the files go:
where the files go
mods
└ chandlery
├ mod.json
└ content
└ economy
└ goods
└ goods.jsonStart from the candle the game already has. It is called candle, it costs 3, it weighs 0.5, it is MANUFACTURED and PRESERVED, and the apothecary makes it. Yours is the same object made of better wax, so it keeps every one of those answers and changes two.
mod.json first:
mod.json
{
"id": "chandlery",
"name": "The Chandlery",
"version": "0.1.0",
"game_version": "0.0.0-dev",
"dependencies": [],
"load_order": 100,
"content_types": ["goods"],
"overrides": [],
"canon_safe": true
}Then content/economy/goods/goods.json:
a goods record
[
{
"id": "chandlery:beeswax_candle",
"display_name": "Beeswax Candle",
"category": "MANUFACTURED",
"decay_tier": "PRESERVED",
"base_price": 9,
"weight": 0.4,
"production_class": "chandler",
"daily_consumption_class": "household",
"tags": ["light", "household"]
}
]| Field | Why this value |
|---|---|
id | Your package name, a colon, then your own word. Anything without your namespace in front of it is refused, so you cannot take somebody else's id by accident. |
display_name | What a player reads. No article — "Beeswax Candle", never "A Beeswax Candle": the sentence around it supplies the article, and which article depends on the language. Words a player reads. |
category | MANUFACTURED, because somebody makes it. The five are raw material, manufactured, consumable, ammunition and equipment, and the one you pick decides which production buildings and which caravans care. |
decay_tier | PRESERVED — 90 days, the same as the game's candle. Wax keeps; it does not keep forever. PERISHABLE is 7 days and ETERNAL never rots. |
base_price | 9. The game's plain candle is 3 and a jar of honey is 8. Beeswax comes from the same hives as the honey and burns without the smell of rendered fat, so it belongs just above honey and about three times the tallow candle. It is not an import — black pepper is 14 and it crossed a sea. |
weight | 0.4, a shade under the plain candle's 0.5. Weight is what a caravan can carry, so a good that weighs nothing and sells high is a bug you are writing on purpose. |
production_class | Which trade makes it. chandler is the one the reference package uses. |
daily_consumption_class | Who burns through it. household means towns consume it steadily, which is what keeps a merchant restocking. |
tags | Free words. Other records and your own later files can search on them. |
Done — now find it in the game. Restart, open Mods, and the line under your row says 1 entry added. That is the whole of what a good on its own does: it is priced and tradeable and it is on no counter anywhere, because nothing stocks it yet. That is not a failure and it is the thing that confuses everyone once. Carry on to A merchant who stocks it.
If it does not show up: Nothing showed up has the six reasons in order. The commonest by far is content_types — a family missing from that line means the folder is never opened, whatever is in it. Then Before you ask anyone for help, which is one button.
An item to carry
You will have the thing a player picks up, stacks in the pack and sells back, paired to the good you just wrote.
Screenshot: not taken yet.
Read first: A good to sell · Names and ids
Copy from: chandlery/content/inventory/items/items.json in your mods folder.
Where the files go:
where the files go
mods
└ chandlery
├ mod.json
└ content
├ economy
│ └ goods
│ └ goods.json
└ inventory
└ items
└ items.jsonA good and an item are two halves of one thing. The good is the economy's view — what a town produces, what a caravan hauls, what a price is. The item is the player's view — what sits in the pack, what stacks, what a shop displays. Grain has a good and no item, because nobody carries a sack of grain around. Candles have both.
Add "items" to content_types in mod.json, then write content/inventory/items/items.json:
a items record
[
{
"id": "chandlery:beeswax_candle_bundle",
"display_name": "Bundle of Beeswax Candles",
"category": "MATERIAL",
"rarity": "COMMON",
"weight": 0.4,
"max_stack": 20,
"base_value": 10,
"sellable": true,
"tags": ["light", "household"]
}
]| Field | Why this value |
|---|---|
id | Its own id, not the good's. They are different records in different families and nothing forces them to match — pairing them is your job, and the merchant row below is where the pairing is made. |
category | MATERIAL, which is what the game's own candle item is. Not CONSUMABLE: nothing is eaten. |
rarity | COMMON. Rarity is what loot tables and shops read when they roll; a common thing turns up. |
weight | 0.4, the same as the good. When the two disagree the player's pack and the caravan disagree about the same object. |
max_stack | 20, the same as bread and the same as the reference package's bundle. Twenty is the game's ordinary stack for small carried things; 1 is for something unique. |
base_value | 10 against the good's 9. A bundle carries a small premium over the loose price — the reference package's tallow bundle is 4 against a 3 good, and this is the same shape one step up. |
sellable | True, or a merchant cannot buy it back off you. |
Done — now find it in the game. An item nothing hands out is loaded and reachable by nothing. In a debug build item chandlery:beeswax_candle_bundle puts one in your pack immediately, which is the fastest proof it loaded — but that command does not exist in the game people download, so it is a shortcut for you and never the answer for a player. The real answer is the next tutorial.
If it does not show up: Nothing showed up, then Before you ask anyone for help.
A merchant who stocks it
You will have a named woman standing in Old Seat with a stall, stock that refills every two days, and your candles on the counter.
Screenshot: not taken yet.
Read first: An item to carry · Now find it in the game
Copy from: chandlery/content/narrative/npcs/npcs.json and chandlery/content/world/vendor_inventories/vendors.json.
Where the files go:
where the files go
mods
└ chandlery
├ mod.json
└ content
├ economy
│ └ goods
├ inventory
│ └ items
├ narrative
│ └ npcs
│ └ npcs.json
└ world
└ vendor_inventories
└ vendors.jsonThis is the one where two families have to agree. The person carries the id of the stall, and the stall carries the id of the person, and each row of the stall carries the id of an item. Three ids, and every one of them has to be spelled the way you spelled it in the other file. It is the mistake that costs an evening, and the Mods screen names it: a row pointing at an id nothing supplies is listed as needs another mod that is not installed.
Add "npc_definitions" and "vendor_inventories" to content_types.
a npc_definitions record
[
{
"id": "chandlery:hesta",
"display_name": "Hesta the Chandler",
"title": "Candle Seller",
"short_bio": "She renders fat behind the tannery and sells what burns longest.",
"voice_tone": "brisk",
"faction": "NONE",
"role": "NONE",
"settlement_id": "old_seat",
"is_vendor": true,
"vendor_inventory_id": "chandlery:candle_stall",
"price_multiplier": 1.0,
"personality_tags": ["brisk", "practical"],
"offers_quest_ids": []
}
]a vendor_inventories record
[
{
"id": "chandlery:candle_stall",
"display_name": "Chandler's Stall",
"npc_id": "chandlery:hesta",
"global_price_multiplier": 1.0,
"buyback_rate": 0.35,
"entries": [
{
"item_id": "chandlery:beeswax_candle_bundle",
"kind": "misc",
"daily_stock": 6,
"price_multiplier": 1.0,
"restock_days": 2,
"min_standing": 0
}
]
}
]| Field | Why this value |
|---|---|
settlement_id | Use an authored settlement id. Settlement ids are authored and stable: Durnwall, Thorngate, Valdmere, Grentham and the twelve villages can all be named. The seed changes the surrounding terrain and biomes, the shortage of the year and the political arrangement, not the id. |
is_vendor + vendor_inventory_id | Together they turn a person into a shop. Either one without the other is a person who cannot sell or a stall nobody minds. |
price_multiplier | 1.0 — she charges what things are worth. Above 1 is a place that knows you have no other option; the game's own vendors sit at 1.0 unless there is a reason on the map. |
buyback_rate | 0.35: she pays about a third of value for what you sell her. This is the number players feel most and the reference package's own stall uses it. |
daily_stock | 6. How many she has when the stall refills. Six is the reference package's figure for a cheap household thing; one is for something a town sees twice a year. |
restock_days | 2, so she refills every second day — and not on the day you install the mod. This is why a new merchant looks empty at first. In a debug build day advances a day. |
min_standing | 0: anyone may buy. Above zero is stock she keeps for people she likes. |
kind | What sort of counter it sits on — misc for a household good. weapon, armor, shield, consumable and ammo are the others. |
Done — now find it in the game. Restart, travel to Old Seat, find Hesta, open the trade screen. If the stall is empty, it has not restocked yet: that is restock_days, not a bug. Three ways the chain can be broken and all three are on the Mods screen — the good with nothing stocking it, the stall pointing at an item id that does not exist, the person pointing at a stall id that does not.
She can also speak. A dialogue_graph whose speaker is her id gives her lines when a player clicks on her, and your package does not have to redefine anything the game already owns to get them — see Conversations of your own. A locales file gives her all of this in seven languages.
If it does not show up: check the three ids against each other first, then Nothing showed up, then Before you ask anyone for help.
A building service
You will have a door in a settlement with your own label and colour on it, opening onto an interior the game already has.
Screenshot: not taken yet.
Read first: A merchant who stocks it · What a mod can never do
Copy from: chandlery/content/world/buildings/buildings.json.
Where the files go:
where the files go
mods
└ chandlery
└ content
└ world
└ buildings
└ buildings.jsonYou are recombining, not building. A buildings record adds a new service with a label, a door colour and a routine role, and it may reuse an interior the game already has by naming it. It cannot ship an interior. An interior is a scene, a scene is code, and a package that names a res:// path or a .tscn is refused before this record is ever read. That is not a gap waiting to be filled — it is the line that makes a stranger's mod safe to install.
a buildings record
[
{
"id": "chandlery:chandlery",
"label": "Chandlery",
"role": "merchant",
"color": { "r": 0.44, "g": 0.38, "b": 0.24 },
"interior_service": "shop"
}
]| Field | Why this value |
|---|---|
label | The words on the door. Left out, the game capitalises your id, which reads like an id. |
role | What the people inside do with their day — merchant puts a shopkeeper's routine behind the door. The role is the game's vocabulary, not yours: an unknown one loads and behaves like nothing in particular. |
color | Beeswax brown, 0.44 / 0.38 / 0.24. Each channel runs 0 to 1, and an HTML hex string works too. Pick something that reads at the size a door is drawn — Art that fits. |
interior_service | shop — the interior the game already draws for a shop. This is the whole trick: you get a real furnished room without shipping one. |
Done — now find it in the game. Restart and walk into a settlement that has your service. A record with no settlement is loaded and placed nowhere, which looks exactly like a record that failed — the Mods screen will say 1 entry added and you will find nothing. Read the row before you look for the door.
If it does not show up: Nothing showed up · Before you ask anyone for help.
A place on the map
You will have a spot on the world map that a player can stumble on, with its own description and its own effect on the company's mood.
Screenshot: not taken yet.
Read first: A building service · Now find it in the game
Copy from: chandlery/content/world/pois/pois.json.
Where the files go:
where the files go
mods
└ chandlery
└ content
└ world
└ pois
└ pois.jsonCoordinates are absolute and the world is generated around authored places. The map is 61 440 × 34 560 world pixels and Old Seat sits at the exact centre, (30720, 17280), in every run. The authored cities and villages keep their names and positions. The seed changes the terrain and biomes around them, the shortage of the year, the political arrangement and which roads are worth taking. A position is still a real, stable point; what is next to it changes every game. Two honest ways to use that: anchor near an authored settlement, so a player will pass it; or mean the emptiness, because a rendering pit nobody has walked past in years is supposed to be nowhere in particular.
a pois record
[
{
"id": "chandlery:tallow_pit",
"kind": "RUINS",
"display_name": "The Tallow Pit",
"description": "A rendering pit behind the tannery. The smell reaches the road.",
"world_position": { "x": 5400.0, "y": 3000.0 },
"discovery_radius": 2.0,
"one_time_loot": false,
"morale_modifier": -1,
"awareness_modifier": 0.0
}
]| Field | Why this value |
|---|---|
kind | RUINS — what the map draws and what the game thinks it is. |
world_position | (5400, 3000), far north-west of the centre: deliberate emptiness, the second of the two honest uses above. Move it within a few thousand pixels of (30720, 17280) and it becomes a place players actually find. |
discovery_radius | 2.0. How close the company has to pass. Large enough to be found on a road, small enough that it is not announced from across a province. |
morale_modifier | −1. It is a pit of rendered fat. The number is small on purpose: a place is a place, not an event, and −1 is a mood, not a punishment. |
awareness_modifier | 0.0 — it does not help you see. Leave a number at zero rather than dropping the field when zero is a decision. |
one_time_loot | False: there is nothing to take, so nothing to take once. |
Done — now find it in the game. Travel there. There is no teleport command, in a debug build or out of it, so pick coordinates you are willing to walk to while you are testing.
If it does not show up: you are probably outside discovery_radius, or the position is somewhere the run's seed put an ocean. Nothing showed up.
Changing what is already there
You will have changed a record the game shipped — and declared it, so the change is visible to the player and to every other mod.
Screenshot: not taken yet.
Read first: Change what the game already has · When two mods want the same thing
Copy from: chandlery/content/economy/goods/goods.json, which adds a record and patches one of the game's in the same file.
Where the files go: nowhere new. A change to something the game already has lives in the same family folder your own records live in — the file says which record it means and what it is doing to it.
where the files go
mods
└ chandlery
├ mod.json the change is declared here
└ content
└ economy
└ goods
└ goods.json your record and the patch, in one fileTwo rules before the file. A change to somebody else's record must be named in overrides in mod.json — a record landing on an id you did not declare is refused, so nothing can quietly rewrite something a player did not agree to. And patch, which changes one field and leaves the rest, needs "manifest_version": 2.
mod.json
{
"id": "chandlery",
"name": "The Chandlery",
"version": "0.1.0",
"game_version": "0.0.0-dev",
"manifest_version": 2,
"load_order": 100,
"content_types": ["goods", "items", "npc_definitions", "vendor_inventories", "buildings", "pois"],
"overrides": ["candle"],
"canon_safe": true
}a goods record
[
{
"op": "patch",
"id": "candle",
"changes": [
{ "op": "set", "field": "base_price", "value": 4 },
{ "op": "append_unique", "field": "tags", "value": "chandlery_touched" }
]
}
]| Choice | Why |
|---|---|
patch, not replace | replace inherits nothing: you would have to restate the candle's weight, decay tier, linked item and production class, and the day the game gives candles a new field, your copy is the only candle in the world without it. |
| 3 → 4 | One coin, and the reason is the mod itself: yours is a town that renders wax properly, so the ordinary candle is worth a little more here. A change from 3 to 30 would not be a change to a candle, it would be a change to every caravan's cargo priority. |
append_unique on tags | The one operation two mods can both do without a conflict. Both appending the same tag is not a fight and does not double it. |
| Naming a field, never a position | tags.0 is refused. The third tag today is the second tag after somebody else's package loads. |
Done — now find it in the game. The Mods screen names the record you took over. So does the other author's screen, if there is one: when two packages write the same id, the loser's row now reads replaced: candle — 'chandlery' writes it last, instead of silently showing one fewer entry than they wrote. Whoever has the higher load_order writes last and wins.
Winning is not the same as being allowed to. Two packages taking over one record, with neither naming the other in supersedes, is reported as undeclared_collision and the composition is not ok — the offline validator says the same thing and refuses:
typed into the debug console
ERROR: conflict on goods:candle: ['early', 'late'] each supply the whole record
and none declares the others under `supersedes`The winner is still worked out, because a refusal must not also be a coin flip, but two authors who both meant it have to say so. When two mods want the same thing is that conversation.
If it does not show up: the usual cause is overrides — the id has to be in that list, spelled exactly, or the record is refused rather than applied. When two mods want the same thing · Nothing showed up.
A mod in ten minutes
1. Make a folder in the mods folder, named lower case with underscores: northern_reach. Everything happens inside it.
2. Write mod.json at the top of it:
mod.json
{
"id": "northern_reach",
"name": "The Northern Reach",
"version": "0.1.0",
"game_version": "0.0.0-dev",
"dependencies": [],
"load_order": 100,
"content_types": ["items"],
"overrides": [],
"canon_safe": true
}id must match the folder name. content_types lists the families you bring — a family missing from that line is never read, whatever is in the folder.
3. Make the folders content/inventory/items and put blades.json in them:
a items record
[
{
"id": "northern_reach:frost_blade",
"display_name_key": "northern_reach.item.frost_blade.name",
"display_name": "Frost Blade",
"category": "weapon",
"base_value": 240,
"weight": 3
}
]The brackets are a list — add as many swords as you like, separated by commas. Every .json file in a family folder is read, so split them however you want.
240 is not a number picked for looking large. One-handed weapons in this game run from 50 to 300, so a frost blade at 240 is an expensive sword and not a legendary one, and a player can afford it about when they could afford the good ones the smith already sells. Where the game's own numbers are is the table to look at before choosing any number at all; a price with nothing beside it is a guess, and in an economy that trades between towns a guess does not stay where you put it.
4. Start the game and open Mods from the main menu. Your package gets exactly one state, and Did it load? has the six of them.
5. Read the line under the row for what was read, applied and refused, with the reason for each refusal. Show details has the ids.
6. Your sword is in the game. Everything after this is more of the same.
Now find it in the game
The report says applied. That means the record is loaded, and it is not the same thing as being able to walk up to it. This is where every guide stops and every author starts guessing.
| What you added | Where it turns up | The catch |
|---|---|---|
goods | On a merchant, once some vendor_inventories row asks for it. A good nothing stocks is loaded, priced, tradeable by anything that names it, and on no counter anywhere. | Adding the good is half the job. The other half is the row. |
vendor_inventories | The merchant named in npc_id. Each row carries its own daily_stock and restock_days, so a row with restock_days: 2 refills every second day and not on the day you added it. | Use day in the console to jump a day rather than waiting for one. |
items | Wherever something hands one over — a vendor row, a loot table, a quest reward. An item with nothing granting it exists and is reachable only with item <id> in the console. | item northern_reach:frost_blade puts it in your pack immediately, which is the fastest way to see that it loaded. |
npc_definitions | The settlement in settlement_id, at the place in location_morning / location_afternoon / location_evening. | Read the next paragraph about settlement_id before you write one. |
buildings | The settlement in settlement, drawn with the role and color you gave it. interior_service is what a player gets when they walk in. | A building with no settlement is loaded and placed nowhere. |
pois | On the world map at world_position, discoverable within discovery_radius. | Read the paragraph about coordinates below. It is the one that catches everybody. |
settlement_id names an authored place. Settlement ids are authored and stable: Durnwall, Thorngate, Valdmere, Grentham and the twelve villages can all be named. The seed changes the terrain and biomes around them, the shortage of the year and the political arrangement, not the id. If you want a person somebody will definitely meet, use any authored settlement id and keep the name in your package's locale data.
Coordinates are absolute, and the world is generated around authored places. The map is 61 440 × 34 560 world pixels and Old Seat sits at the exact centre of it, (30720, 17280), in every run there has ever been. The authored cities and villages keep their names and positions. The seed changes the terrain and biomes around them, the shortage of the year, the political arrangement and which roads are worth taking. So a world_position of (6000, 2200) is a real, stable point in the far north-west of the map, and what is near it is different in every run: it might be a road, a forest or the middle of nowhere. Two ways to use that honestly:
- Anchor to the centre. A POI a few thousand pixels from (30720, 17280) is near Old Seat in every run, so it is a place a player will find.
- Mean the emptiness. A ruin nobody has walked past in a hundred years is supposed to be nowhere in particular. Absolute coordinates are exactly right for that, and the seed will decide what its neighbours are.
The console commands that save the most time. day [n] and hour [n] advance time, which is how you see a restock without living through it. item <id> [count] and gold <amount> put things in your hands. flag, quest_start and faction_rep move the state a condition might be waiting on. There is no teleport command: to reach a place, travel to it.
mod.json, field by field
| Field | What it does |
|---|---|
id | In the example above. Your package name. Must match your folder exactly, and it is the word before the colon in every id you write. |
name | In the example above. What a player sees. Capitals and spaces are fine here. |
version | In the example above. Yours to choose. Raise it whenever you change anything. |
game_version | In the example above. The build you made it for. |
content_types | In the example above. The families you are bringing. A family missing from this list is never read, whatever is in the folder. |
dependencies | In the example above. Packages yours needs. A name, or an object with optional: true. |
load_order | In the example above. Who goes first when two packages touch the same thing. Lower loads earlier. |
overrides | In the example above. Ids from the base game you intend to change. An id here, or a file.json:id when you mean one file's copy of it. |
canon_safe | In the example above. Whether your mod fits the game's own story. This is you telling players, not the game deciding. |
roots | A family's folder, when you want it somewhere other than the default in The families, one by one. It stays inside your package. |
manifest_version | Which manifest shape you are writing. Change what the game already has's operations need 2. This build reads up to 2. |
type | What kind of package this is. The fourth value is the one that ships behaviour rather than data, and the only thing in this build that can say yes to running behaviour says no, so a package declaring it is refused. It is in the list because the shape is settled, not because the door is open; What a mod can never do is the honest reading. One of content, overhaul, total_conversion, feature_extension. |
capabilities | What a package would ask the host for, if the tier above ever opened. An unknown field here fails closed rather than being ignored. |
trust_tier | Untrusted, which is what a mod a player downloads is and stays. A host names the other one; nothing a package writes here promotes it. One of untrusted, trusted. |
supersedes | Packages this one means to overrule. A field claimed this way stops being reported as a clash. An unknown field here fails closed too. |
fallback_policy | What a package falls back to when something it supersedes is absent. One of vanilla, none. |
campaign | The campaign a conversion owns. |
campaign_package | Declares that this package is a campaign, with its own id, name and version. A package that carries this is only read while its campaign is the one running, and a player picks it in the Mods screen before a new game. A total conversion. |
A dependency may be an object instead of a name: {"id": "some_mod", "version": "", "optional": true}.
A value naming another package is a cross-mod reference and the reference has to be a declared dependency. If that dependency is optional, give the field a fallback, or the record is skipped when the dependency is absent:
a goods record
[
{
"op": "add",
"id": "northern_reach:alloy",
"display_name": "Alloy",
"linked_item_id": "absent_helper:ore",
"fallback": { "linked_item_id": "iron_ore" }
}
]Where each file goes
where the files go
northern_reach/
mod.json the label
content/assets/images/
images.json your art
content/inventory/items/
blades.json your first record
content/narrative/quest_plans/
the_long_winter.json your questNames and ids
An id is lower case letters, digits and underscores, and nothing else. frost_blade, not Frost Blade, not frost-blade and not frost.blade. It carries no separator of its own either, because a separator inside an id is the folder leaking into the name: move the file and the id disagrees with the disk. The : above is the one exception, and both halves of it obey the rule.
The part before the colon is your package id. It is how two mods can both add a Frost Blade without colliding.
Words a player reads
Text written into a record is text in one language. Point at a key instead:
a locale CSV
northern_reach.item.frost_blade.name
northern_reach.item.frost_blade.descriptionKeys are lower case, joined by dots, namespaced like ids. Ship the words in the locales family — a .csv under content/locale with a key column and one column per language. The families, one by one has the exact shape.
Write the plain display_name beside the key as well. A record with only a sentence has to be rewritten by hand the day you translate it, and by then there are four hundred of them.
The two ways of getting this wrong fail very differently, and only one of them tells you. A key nothing answers shows up as the key itself — northern_reach.item.frost_blade.name, on the screen, deliberately ugly so that somebody fixes it, and counted as a gap. A record carrying only text and no key never shows up at all: it works, in your language, for every player in every language. Nothing is refused and nothing is missing, so nothing reports it, and a player reading in Portuguese quietly reads your Italian. The first mistake you find. The second one finds your readers.
Translating a mod, and being translated
The game ships in seven languages, so a package with English baked into its records reads as English to six-sevenths of the people who install it. This is what it costs to avoid that, which is almost nothing if you do it on the first day and a rewrite if you do it on the four hundredth.
The languages there are
Seven, and this table is read out of the build rather than typed here, so a language arriving or leaving moves it the same day:
| Write this | Language | Column header | |
|---|---|---|---|
en | English | en | the source language |
it | Italiano | it | |
fr | Français | fr | |
de | Deutsch | de | |
es | Español | es | |
pt-BR | Português (Brasil) | pt_BR | |
zh-Hans | 简体中文 | zh_Hans |
Two of the codes have a second spelling in the wild — pt-BR is what a storefront and this page use, pt_BR is what the engine standardises to — and both are accepted in a column header. Write whichever you copied.
Shipping words in more than one language
One CSV under content/locale, key first and one column per language:
a locale CSV
key,en,it,de
northern_reach:GOOD_FROST_SALT,Frost Salt,Sale di Brina,Frostsalz
northern_reach:ITEM_FROST_BLADE_NAME,Frost Blade,Lama di Brina,Frostklinge| Rule | Why |
|---|---|
| Every key is in your namespace | northern_reach:GOOD_FROST_SALT, with the same colon ids use. A key outside it is refused unless overrides declares it — otherwise two packages both writing GOOD_GRAIN would be settled by load order, silently. |
| A column naming a language the game does not ship is refused | Not ignored. A column the loader quietly dropped would ship nothing and you would hear about it from a player. |
| An empty cell is not an answer | It is skipped rather than stored, so a language you have not got to yet falls through to the next rung instead of going blank on screen. |
| Never both | A record carrying a _key field and a verbatim string for the same thing is refused. The key is the promise; the string is what the key replaced. |
When a key has no answer in the player's language the game works down a fixed ladder — the player's language, then the language your package declares it was written in, then the game's own catalogue, then the key itself. The last rung is ugly on purpose: an unresolved key has to look wrong so that somebody fixes it. A total conversion skips the game's catalogue, because Old Seat surfacing in a world set on another continent is not a small mistranslation, it is the base game showing through.
Writing text that can be translated at all
- No sentences built out of pieces. Word order is not the same in every language, so a name assembled at runtime from an adjective column and a noun column produces Oro Cervo in Italian. One authored entry per language, complete.
- A person's name is never translated and never inflected. Aldric is Aldric in every language. That also means a key must not put a name in an oblique position —
"of {name}","{name}'s"— because several languages decline proper nouns and the game does not. - There is no character limit, and that is not the same as no limit. No field counts characters; what runs out is room on the screen. German runs about a third longer than English and is the language that breaks buttons; Chinese is far shorter and almost never does. If you test one translation, test German, and give a label the shortest wording that is still the right wording.
Somebody translating your package
They add a column and send you the file. That is the whole workflow, and it is why the format is CSV rather than something friendlier to a programmer: a translator can open it in a spreadsheet, and their tools already read it.
Two things make the difference between a package that gets translated and one that does not. Keep the keys stable — a renamed key is a lost translation, and the translator finds out when a player does. And say what a line is for: a key called NR_MSG_01 gets a guess, while northern_reach:VENDOR_HERB_GREETING gets a greeting from a herb seller.
If you are the one translating somebody else's package, the same rules bind you: the columns are the languages the game ships, the keys are theirs and not yours, and a name stays a name.
Change what the game already has
| You want to | Operation | What you need |
|---|---|---|
| Add something new | add | A namespaced id of your own. Nothing else. |
| Rewrite one of the game's records | replace | The record's id listed in overrides. A full record — it inherits nothing. |
| Change one field and leave the rest | patch | overrides, plus "manifest_version": 2 in mod.json. |
| Withdraw a record | remove | overrides, and a family that takes it. See Taking something out. |
part of mod.json
"overrides": ["iron_sword"]overrides is a promise. A record landing on an id you did not declare is refused, so you can never quietly change something a player did not know about.
a goods record
[
{
"op": "patch",
"id": "iron_ore",
"changes": [
{ "op": "set", "field": "base_price", "value": 14 }
]
}
]| Operation | What it does | It needs | It refuses |
|---|---|---|---|
add | Supply a complete new record under your own namespace. | namespaced id | an id that already exists, whether vanilla or from another mod |
replace | Supply a complete record for an id that already exists, having declared it. | declared override, complete record | an id absent from the registry, and an id the manifest does not list under overrides |
patch | Change named fields of an existing record and leave the rest alone. | declared override, changes | an id absent from the registry, a field the family does not allow, and any change form outside patch_operations |
remove | Withdraw an existing record and leave a tombstone in its place. | declared override, removable family | an id absent from the registry, and any family not marked removable below |
A patch names its changes one at a time:
- set — Write
valueatfield, whatever was there before. Written withfieldandvalue. - unset — Drop
fieldfrom the record, so the reader sees whatever the family's default is. Written withfield. - append_unique — Add
valueto the list atfieldif it is not already there. Written withfieldandvalue. - remove_value — Drop every occurrence of
valuefrom the list atfield. Written withfieldandvalue.
- Name a field by path, never by position.
tags.0is refused. The third tag today is the second tag after somebody else's mod loads. - A path is up to four lower-case names joined by dots. Anything with an operator in it is refused.
append_uniqueis the one two mods can share. Both appending the same tag is not a conflict and does not double it.
Only sometimes
A record can say when it counts. Add a when next to the op, in a "manifest_version": 2 package:
a presentation_bindings record
{
"op": "add",
"id": "my_mod:snowy_cloak",
"subject": "wool_cloak",
"kind": "item",
"slot": "icon",
"cue": "my_mod:cloak_snowy",
"when": { "season": { "is": "winter" } }
}There is no operator and nothing to calculate. Everything a condition can say is in these two tables.
| Ask about | Settled | Tests | Values |
|---|---|---|---|
packages | at load | all_of, none_of | mod ids loaded beside yours |
campaign | at load | is, any_of, not_any_of | the campaign the run started from |
locale | at load | is, any_of, not_any_of | the language being played in |
season | while playing | is, any_of, not_any_of | spring, summer, autumn, winter |
weather | while playing | is, any_of, not_any_of | a weather id the game rolled |
day_phase | while playing | is, any_of, not_any_of | dawn, day, dusk, night |
day | while playing | at_least, at_most | how many days the run has lasted |
| Test | Takes | True when |
|---|---|---|
is | one name | the answer is exactly that |
any_of | a list | the answer is one of them |
not_any_of | a list | the answer is none of them |
at_least | a number | the answer is that or higher |
at_most | a number | the answer is that or lower |
all_of | a list | every one is present |
none_of | a list | none is present |
Four subjects at most in one when, two tests per subject. Two tests on one subject are a range: { "day": { "at_least": 10, "at_most": 30 } }.
Which families take which. Every family takes the three settled at load. Only presentation_bindings and effects take the four that move while playing, because only those two are rebuilt when the world changes. Naming season anywhere else is refused, and the message says which subjects that family does take.
The reason is saves. A record that decides an item, a person or a quest has to be the same record on the next load, and a condition that moves is exactly what makes it not be.
A seasonal look goes on the binding, not on the picture. Ship the image once, then two presentation_bindings records for it — one with a when, one without. images, audio and fonts refuse a when outright.
Withheld is not rejected. A condition that is not met withholds its record and the load report says so by name:
what the game prints back
WITHHELD my_mod:snowy_cloak from my_mod: season is winter, and it is springA package with fifty seasonal records is three-quarters withheld at any moment. That is the package working.
If every conditional presentation binding is withheld, the report still shows the family as declared and prints bound=0 together with each WITHHELD row; it does not misreport the package as having no presentation surface. If no unconditional binding supplies the same slot, it also prints NO_APPLICABLE kind/subject/slot with the unanswered condition, which means “nothing applies in this world state”, not “the record was malformed”. Test the condition in the host or runtime lane with the matching world state before assuming the binding was rejected.
Two versions of one thing. Ship two add records with the same id and different conditions. The one whose condition holds is the one that loads. If both hold, the second is refused as a duplicate — which is how overlapping conditions get reported instead of settled by file order.
Taking something out
remove means out of supply, not out of existence. The record keeps resolving and is marked: nothing new is offered, stocked, crafted or spawned from it, and everything a player already has that names it still reads exactly what it always did.
| Family | remove | What happens |
|---|---|---|
goods | yes | Withdrawn. Still resolves by id, gone from what the game offers. |
vendor_inventories | yes | Erased. The row is dropped. |
| everything else | no | Refused, with the reason. |
a goods record
{ "op": "remove", "id": "iron_ore" }vendor_inventories is the only family that drops the record, and only because the save rules already say nothing points at that row.
A remove cannot carry a when. A withdrawal a save may name has to be settled, or an id resolves on one evening and not on the next.
A removal is final for one composition. A later patch, replace or second remove targeting the withdrawn ID is refused, even though a goods withdrawal keeps the old record resolvable through its tombstone. This prevents the save-facing report from saying "withdrawn" while a later package makes the record behave as supplied again. Add a new namespaced ID when you need a new record; do not rely on removal followed by silent resurrection.
When two mods want the same thing
Both load. Every change is composed before anything is built, so the game sees both.
| What clashes | What happens |
|---|---|
| Different fields of one record | Both apply. Nothing is lost. |
| The same plain field | Later load_order wins, and an undeclared clash is an error: undeclared_collision, and the composition is not ok. |
| Two packages supplying the whole record | The same. The winner is decided by load_order and the plan still refuses until somebody declares it. |
The same list, via append_unique / remove_value | No conflict. Both say what the list must contain, not what it must be. |
| One package withdraws a record, another patches or replaces it | The withdrawal stands. The later operation is refused, whatever the load order. |
| Two packages dressing one subject | Later load order wins, loser named in the report. |
| A package dressing another package's content | Refused. |
Winning and being allowed are two different questions, and this is the part that surprises people. The game always works out which package writes last — leaving that to chance would make a refusal a coin flip as well as a refusal — and it still reports the pair as a collision nobody declared. The offline validator refuses the same pair outright, so you find out before a player does.
supersedes in your manifest is how you say you meant it. Name the other package's id and the clash becomes declared_replacement: recorded, with the reason, and no longer an error.
Give your package a load_order of its own. Two packages sharing a number is not refused, and the winner then falls out of alphabetical mod id — an order neither author chose and no player can read off the screen.
Pictures, sound, lettering, colour
Shipping a file and using it are two separate steps and you need both. A file with nothing pointing at it is read, checked, measured and never shown.
| Kind | Files | Installed | What that means |
|---|---|---|---|
image | .png, .webp | Yes | The file is read and the texture is built. |
audio | .ogg, .wav | No | Descriptors are validated, the header is read and the duration bounded, and nothing plays one: no content family carries audio and the audio feature asks no catalogue for a package cue. Ship sound when the family exists; today it would be measured and silent. |
font | .ttf, .otf | No | Descriptors are validated and locale coverage is checked, and no theme resolves a package font. The interface builds its faces from the shipped theme, so a font a package supplies would be verified and never set on anything. |
Ship the file
Each asset family is a descriptor file plus the files it names. The descriptor must be called after its family — images.json, audio.json, fonts.json.
where the files go
content/assets/images/
images.json
river_guard.png| Family | Folder | Accepts | Required fields |
|---|---|---|---|
images | content/assets/images | .png, .webp | id, file, format |
audio | content/assets/audio | .ogg, .wav (uncompressed) | id, file, codec, role |
fonts | content/assets/fonts | .ttf, .otf | id, file, format, sizes, covers, fallback_font |
themes | content/assets/themes | colour tokens | id, tokens |
effects | content/assets/effects | host templates | id, template |
a images record
[
{
"id": "northern_reach:river_guard",
"file": "river_guard.png",
"format": "png"
}
]Three rules that bite:
- The header is checked before anything decodes it.
formatis what the file is, not what the extension claims. - A
filename containing..or~, or starting/,\,res://oruser://, is refused on the name. - An id that does not resolve gets a placeholder and a report, never silence.
Point at it
On a record you ship, with a field that takes an image id of yours:
| Family | Field | What it is |
|---|---|---|
items | icon_id | The icon on an item you ship. |
npc_definitions | portrait_id | A named NPC's portrait. |
perks | icon | A perk's icon. |
On anything at all, including the base game's content, with a presentation_bindings record naming the subject, its kind and the slot:
a presentation_bindings record
[
{
"id": "northern_reach:iron_sword_icon",
"subject": "iron_sword",
"kind": "item",
"slot": "icon",
"cue": "northern_reach:river_guard",
"alternative_key": "northern_reach_iron_sword_icon_alt"
}
]| Slot | You ship | Where it appears | alternative_key | Drawn |
|---|---|---|---|---|
portrait | image | The dialogue frame, by the speaker's id. | required | yes |
sprite | image | A troop token, world-map ground, or a building in a settlement. | — | yes |
icon | image | Inventory cells, for any item — yours or the game's. | required | yes |
banner | image | The house's colours on its journal page. | — | yes |
voice | audio | Plays when that speaker's line appears. | required | not yet |
use_sound | audio | Plays when an item is used and the use took effect. | — | not yet |
ambience | audio | Loops while the player stands on that ground. | — | not yet |
theme | theme | Which of your palettes the interface wears. | — | yes |
font | font | Which of your faces the interface wears. | — | not yet |
effect | effect | Weather and light on the ground it is bound to. | — | yes |
alternative_key is required where a slot carries information: it is the locale key that says the same thing for a player who cannot receive the picture.
Sound
role is a bound, not a label — each carries its own maximum length, its own limit on how many play at once, and the slider a player reaches for.
| Role | For | Slider |
|---|---|---|
music | Tracks | Music |
ambience | Rooms, weather, places | Ambient |
sfx | Short effects | Effects |
voice | Spoken lines | Effects |
A compressed WAV is refused: decoding one needs a parser, and the parser is the thing being avoided.
Lettering
covers names the languages your face carries glyphs for, and the build refuses to install it for any other. A face that lacks a language does not fail — it draws boxes, and the screen reads as a half-finished translation with the text correct and the font at fault.
fallback_font is required: name another font id of yours or a system family (Segoe UI, Noto Sans). sizes is what the glyph cache is budgeted against.
One face, never a collection — a .ttc holds several and which one arrives depends on an index the descriptor cannot carry. Free faces with wide coverage: Google Fonts, Font Squirrel. Check the licence lets you redistribute the file; your package ships it.
Colour
Tokens, never a scene, a script or a resource path. The names are the game's: bg_deep, bg_mid, bg_button, bg_hover, border, border_hi, text, text_dim, accent, gold. A name this build does not know is named in the report rather than left looking applied.
a themes record
[
{
"id": "northern_reach:dusk",
"tokens": {
"bg_deep": { "kind": "colour", "value": "#12131a" },
"text": { "kind": "colour", "value": "#e8e2d0" },
"border": { "kind": "colour", "value": "#7f6ab0" }
},
"contrast_pairs": [
{ "text": "#e8e2d0", "background": "#12131a" }
]
}
]contrast_pairs are checked to a ratio of 4.5 and the theme is refused below it. WebAIM's checker tells you a pair's ratio before you ship.
Turn the package off and the interface takes its own colours and lettering back.
Art that fits
The build checks that a picture is a picture. It has nothing to say about whether it looks like it belongs, and in a world drawn in greys a bright, cheerful, high-resolution sword does not stop being wrong for passing every check. This is what is known today.
| The register | Pixel art, grey and unheroic. Muted, worn, weather-stained. A palette that would suit a wet afternoon in a town that has had a bad year. |
| Tiles | 16 pixels, for anything drawn into a map. Maps of your own. |
| Formats and bounds | .png or .webp, up to 8192 on a side, up to 8 MB, checked from the header before anything decodes it. |
| Interface colour | The ten themes tokens, and a contrast_pairs ratio of 4.5 the theme is refused below. |
The full style guide is not published yet. What it will carry: the palette itself, the sizes each kind of thing is drawn at, and side-by-side examples of what sits in the world and what stands out of it. It is worth waiting for rather than guessing, because the failure it prevents is not a refused file — it is a package that installs perfectly and makes the game look like two games.
Until it exists, the reliable method is the one that needs no document: put your picture next to the game's own and look at the two of them together.
Maps of your own
Draw in Tiled, export .tmj, and index it in content/world/maps/maps.json.
a maps record
[
{
"id": "northern_reach:river_bend",
"file": "river_bend.tmj",
"name_key": "NORTHERN_REACH_RIVER_BEND",
"world_origin": [4096, 2560],
"region_id": "northern_reach:lowlands"
}
]| Rule | What it means |
|---|---|
| The kit | 16-pixel tiles, orthogonal, right-down, finite. |
| Layers are roles, not names | ground, collision and walkable are required. A layer called collisions is refused rather than ignored. |
| Geometry is declared | Collision and walkable area are objects you draw, never derived from what the tiles look like. |
| Every object name is namespaced | northern_reach:towpath, not towpath. |
world_origin | Where your top-left corner sits in world pixels. Declared, because a map file has no idea where in a world it is. |
| A map says where, never what | A poi object on the bindings layer names a POI you shipped as a record; the layout moves it to where you drew it. |
| All or nothing | A layout that refuses places nothing at all. |
| No paths | A property named path, scene, script, resource or template is refused by its name, whatever is in it. |
A binding naming content another package owns — or the base game's — refuses the whole layout. modding_map_report names the layout and the rule that refused it.
Quests of your own
A quest is a set of records that only make sense together, in content/narrative/quest_plans.
| Kind | What it is |
|---|---|
arc | The story. Lists the quests in it. |
quest | The job: who gives it, what it is called, what it pays. |
objective | One thing the player has to do. |
ending | How the arc finishes. |
a quest_plans record
[
{
"kind": "arc",
"id": "northern_reach:the_long_winter",
"stages": ["northern_reach:bring_the_grain"]
},
{
"kind": "quest",
"id": "northern_reach:bring_the_grain",
"name_key": "northern_reach.quest.bring_the_grain.name",
"giver": "northern_reach:the_reeve",
"in_arc": "northern_reach:the_long_winter",
"objectives": ["northern_reach:gather_grain"],
"rewards": [
{ "grant": "receive_coin", "coin": 250 }
]
},
{
"kind": "objective",
"id": "northern_reach:gather_grain",
"quest": "northern_reach:bring_the_grain",
"goal": "collect_item",
"target": "northern_reach:winter_grain",
"count": 40
},
{
"kind": "ending",
"id": "northern_reach:the_barns_are_full",
"arc": "northern_reach:the_long_winter",
"requires": ["northern_reach:bring_the_grain"]
}
]| Goal | Watched today |
|---|---|
collect_item | yes — you are carrying enough of something |
defeat_unit | yes — you have killed enough of something |
earn_coin | yes — you have enough money |
reach_place | yes — you have visited a settlement |
survive_days | yes — enough days have passed |
speak_to, deliver_item, defend_place, escort_unit, learn_lore | no — refused by name rather than accepted and never completable |
| Reward | Handed over today |
|---|---|
give_item, receive_coin | yes |
change_relation | yes, when the subject is the player |
| the other five | refused out loud |
- A plan is all or nothing. One wrong record and none install — the alternative is an arc that stops halfway with the player holding the sword and not the title.
- A reward is a
grant, never akind.kindsays which of the four records this is. A reward written withkindreads perfectly and is refused.
Check yours with modding_quest_plan_report.
Conversations of your own
A conversation is three kinds of record in content/narrative/dialogue: the dialogue_graph that names it and says which beat it opens on, the dialogue_node beats that are the lines, and the dialogue_option choices standing on them.
Your line is your line, and nothing rewrites it. What a local model can do is answer a question the player types at the character, staying inside what your line says — and when it cannot stay inside it, it says it does not know. The player decides whether that surface exists at all; with the model off, the character says your sentence and stops. Translation of your line happens through the locale files, the same as everything else you write.
The graph names a speaker, and that is the whole trigger. Every character in the game asks this family what conversations stand on them when they are clicked, so you can give the blacksmith something to say without redefining the blacksmith.
a dialogue record
[
{
"kind": "dialogue_graph",
"id": "northern_reach:gate_toll",
"entry": "northern_reach:gate_open",
"speaker": "gate_warden",
"name_key": "TALK_PACK_GATE"
},
{
"kind": "dialogue_node",
"id": "northern_reach:gate_open",
"graph": "northern_reach:gate_toll",
"voice": "line",
"speaker": "gate_warden",
"line_key": "TALK_PACK_GATE_OPEN"
}
]You write the conversation, never the words: every line is a locale key, so the same package reads in every language the player has. A package that ships prose is refused at the file rather than at the screen.
A beat and a choice may each carry conditions, and a choice may carry commands. A condition decides whether the player is shown the thing at all; a command is what taking it does.
| Check | Takes | Asks | Answered today |
|---|---|---|---|
companion_present | companion | That companion is standing there to hear it. | yes |
faction_stance | faction, stance | A faction stands where the option expects. | yes, for hostile — any other value is refused before the first line |
has_coin | coin | The player can afford it. | yes |
has_item | item, count | The player is carrying it. | yes |
has_knowledge | lore | The player has been told this. | yes |
has_trait | trait | The player was built that way. | yes |
lacks_knowledge | lore | The player has not been told this yet. | yes |
quest_state | quest, state | A quest stands where the option expects. | yes |
relation_at_least | subject, target, regard | One party thinks at least this much of the other. | yes |
season | season | It is that part of the year. | yes |
time_of_day | phase | It is that part of the day. | yes |
| Command | Takes | Does | Performed today |
|---|---|---|---|
change_relation | subject, target, delta | Move a standing, within the host's published relationship rules. | yes |
give_item | item, count | Hand something over. | yes |
grant_knowledge | lore | Tell the player something. | yes |
open_shop | vendor | Put a vendor's stock on screen. | yes |
pay_coin | coin | The player pays. | yes |
receive_coin | coin | The player is paid. | yes |
recruit_companion | companion | Add somebody to the party when the host's recruitment rules permit it. | yes |
set_quest_state | quest, state | Move a quest to a state its own record allows. | yes, for active, failed — any other value is refused before the first line |
start_combat | encounter | End the talking. | yes |
take_item | item, count | Take something away. | yes |
travel_to | destination | Move the party. | yes |
- A conversation naming something this build cannot do never opens. Not the choice that uses it — the whole conversation, before its first line. A talk that stops at the third option has already taken the player's coin.
- Nothing moves more than there is. Ask first with
has_coinorhas_item; a package that forgets cannot put the player at minus forty coins, because nobody agreed to a debt and no screen explains one. - Every beat needs a way out. A beat a player can arrive at and never leave is refused when the graph loads.
Check yours with modding_dialogue_report.
Scenes of your own
A scene is the one kind of content that takes the controls away from the player, so it is also the one with a rule about ending. Scenes live in content/narrative/scenes and each names a where — the place it plays in. Walk into that place and the scene plays.
a scenes record
[
{
"id": "northern_reach:the_morning_after",
"where": "northern_reach:waystation",
"beats": [
{ "beat": "fade_in", "seconds": 1.5 },
{ "beat": "lock_player" },
{ "beat": "say", "actor": "gate_warden", "line_key": "SCENE_PACK_GREETING" },
{ "beat": "wait", "seconds": 1.0 },
{ "beat": "unlock_player" }
]
}
]| Beat | Takes | Timing | Played today |
|---|---|---|---|
ask_name | prompt_key | the scene waits for it | yes |
end | — | the scene carries on | yes |
fade_in | seconds | the scene waits for it | yes |
fade_out | seconds | the scene waits for it | yes |
lock_player | — | the scene carries on | yes |
narrate | line_key, seconds | the scene waits for it | yes |
play_cue | cue | the scene carries on | yes |
remove_actor | actor | the scene carries on | yes |
say | actor, line_key | the scene waits for it | yes |
show_exit | — | the scene carries on | yes |
start_dialogue | graph | the scene waits for it | yes |
unlock_player | — | the scene carries on | yes |
wait | seconds | the scene waits for it | yes |
walk_to | actor, anchor | the scene waits for it | yes |
- A scene that takes the controls must give them back. A sequence ending without
unlock_playeris refused, because the alternative ships a room the player can only close the game to leave. - Beats play in order and a blocking beat is waited for. That is what makes a scene a scene rather than a list of things happening at once.
Check yours with modding_scene_report.
Screens of your own
A screen layout says which sections stand in which panel of a screen the game already has, and in what order. It arranges pieces this build draws; it cannot add one, because a section nobody wrote has nothing to render.
Layouts live in content/ui/screens. The families, one by one has the fields and a record to copy.
Check yours with modding_screen_report.
Nothing showed up
Read the state on the Mods screen first. Most of the states name the problem outright, on the row, and so does the commonest mistake of all — the one that used to be first on this list.
The family missing from content_types. You write goods.json, you leave "goods" off the manifest's list, and the folder is never opened: nothing is refused, because nothing was read. The package loads with nothing in it. The screen now says which file it walked past and what to add:
what the game prints back
content/economy/goods/goods.json was not read — add "goods" to content_types in mod.jsonThe offline validator prints the same sentence, so the two cannot disagree about it. Only if the row says loaded and names no unread file, work down this list. It is in order of how often each one is the culprit.
| # | Symptom | What the validator says |
|---|---|---|
| 1 | The folder path is wrong | WARN: declared goods root missing: …/content/economy/goods. A warning, not an error — the package still passes, which is why --strict before you publish is the advice it is. The families, one by one has the exact folder for every family. |
| 2 | The id has no colon | ERROR: add needs an id under your own namespace. frost_blade is refused; northern_reach:frost_blade is accepted. |
| 3 | The JSON is broken | ERROR: invalid json goods.json: Expecting ',' delimiter: line 6 column 5. One missing comma makes the whole file unreadable, and you are told the line. |
| 4 | The file is in the wrong package | The same message as #2. An id namespaced to somebody else is not yours to write, and the check cannot tell that apart from a typo — it only knows the prefix is not your package id. |
| 5 | A required field is missing | The report names it — for the fields a family actually requires, which is fewer than you would guess. It showed up, and it is wrong is the case where nothing is reported at all. |
The reports never guess. A refused record is named, with what was wrong with it, and usually a code — see Every diagnostic code.
It showed up, and it is wrong
The harder case, because nothing is reported at all. Two things pass every check and still give you a record you did not write.
A field you left out is not an error — it is a default. Each family's schema says which fields a record cannot install without, and for most families that list is short. goods requires exactly one: id. Leave out base_price and it is 1. Leave out weight and it is 1.0. Leave out category and the good is a raw material. Nothing warns you, because nothing is wrong: you asked for a good and you got one.
A value that is not one of the choices is also a default. The reader upper-cases before it looks, so manufactured and MANUFACTURED are the same value and case is never the problem — do not go rewriting files over it. MANUFACTURED_GOODS is the problem: it is not a member, so it becomes RAW_MATERIAL. SLOW for a decay_tier becomes ETERNAL. The record installs, the screen says applied, and your candle is priced and spoils as something else.
Nothing in the game says so, because there is nothing to say: the field is declared as a string and a misspelt string is still a string. The validator is the only place it can be caught, and it now is:
typed into the debug console
$ python tools/quality/validate_mod_data.py --root mods/northern_reach
WARN: northern_reach:goods:goods.json#1: category is "MANUFACTURED_GOODS",
which is not one of RAW_MATERIAL, MANUFACTURED, CONSUMABLE, AMMUNITION,
EQUIPMENT. It loads as RAW_MATERIAL.
ERROR=0 WARN=1A warning and not an error, because the record does load and refusing it now would break packages that already work. --strict turns it into one, which is the whole reason to run --strict before you publish.
A field you left out stays silent, and that one cannot be caught: leaving category out and writing RAW_MATERIAL are the same request. So the field tables in The families, one by one mark Required and give the values column as the shape the loader checks. When a record loads and behaves like something else, read your fields against that table first: what you are looking for is a field you did not write, or a word the loader did not recognise.
Reference
Every family
One folder and one report per family. The status column is measured from the build, so it is never a promise the game does not keep. What it is is the opening line only, so that the table stays something you can run your eye down; each family's description in full, with its fields and a record to copy, is its entry in The families, one by one.
| Family | Status | Folder in your package | Report command | What it is |
|---|---|---|---|---|
adaptive | Ready to use | content/player/adaptive | modding_adaptive_report | Profiles that watch how a run is going and suggest a different setting for it. |
audio | Validated, nothing consumes it yet | content/assets/audio | modding_audio_report | Sounds a package ships, named by id: music, ambience, effects and voice, each pointing at a file inside its own folder and declaring the role that bounds it. |
buildings | Ready to use | content/world/buildings | modding_building_report | Building and settlement-service definitions: label, colour, role and a reused interior. |
caravans | Ready to use | content/logistics/caravans | modding_caravan_report | Trade routes a caravan runs: where it starts, where it is going, what it carries, how well it is guarded and how fast it moves. |
combat_balance | Ready to use | content/combat/balance | modding_combat_balance_report | Bounded combat balance values that tune existing resolver parameters without adding rules, operations or mechanics. |
dialogue | Ready to use | content/narrative/dialogue | modding_dialogue_report | Conversations as records: the graph, its beats and the choices standing on them. |
effects | Ready to use | content/assets/effects | modding_effect_report | Weather, dust, sparks and lights, composed out of the templates the host implements. |
enemy_archetypes | Ready to use | content/threat/enemy_archetypes | modding_enemy_archetype_report | Kinds of enemy the strategic layer knows about: tier, which variants they field, where they camp, how large the garrison is and how often they raid. |
enemy_variants | Ready to use | content/combat/enemy_variants | modding_enemy_variant_report | Stat blocks for a kind of enemy body on the field: class, tier, the five attributes and the weapon, off-hand and shield ids it carries. |
fonts | Validated, nothing consumes it yet | content/assets/fonts | modding_font_report | Typefaces a package ships, named by id: the file, the sizes it renders at, and the languages whose glyphs it carries. |
goods | Ready to use | content/economy/goods | modding_goods_report | Tradeable goods and their economic properties. |
group_archetypes | Ready to use | content/strategic/group_archetypes | modding_group_archetype_report | Strategy tunables for a group that lives on the world map: alarm radius, target preference, member counts, supply and retreat thresholds. |
images | Ready to use | content/assets/images | modding_image_report | Images a package ships, named by id: icons, portraits and sprite sheets, each pointing at a file inside its own folder. |
input_bindings | Ready to use | content/input | modding_input_report | Actions of a package's own, with the key it would like. |
items | Ready to use | content/inventory/items | modding_items_report | Inventory items, including weapons and armour. |
locales | Ready to use | content/locale | modding_locale_report | The words a player reads, as CSV with a column per language: the package's own keys, and the game's own where the package declares the override. |
lore | Ready to use | content/narrative/lore | modding_lore_report | Codex entries: places, people, events, customs and things a world tells stories about. |
maps | Ready to use | content/world/maps | modding_map_report | Layouts a package draws in Tiled, named by id: the file, where it sits in the world, and the language key it is known by. |
mercenary_templates | Ready to use | content/character/mercenaries | modding_mercenary_report | Hireable mercenary blueprints: name, class, background, starting health and morale, hire cost and the day they first appear. |
mr_lines | Ready to use | content/medieval_realism/structured | modding_mr_lines_report | Structured medieval-realism lines, addressed by file and row id rather than by a JSON id field. |
noble_houses | Ready to use | content/politics/noble_houses | modding_noble_house_report | Noble house seed data: the lord, the heir, which settlements answer to the house, and who it counts as rival or ally. |
npc_definitions | Ready to use | content/narrative/npcs | modding_npc_report | Named NPC definitions. |
perks | Ready to use | content/character/perks | modding_perk_report | Declarative perks: triggers and effects, with no script handler. |
pois | Ready to use | content/world/pois | modding_poi_report | Points of interest placed on the authored world map by coordinate. |
presentation_bindings | Ready to use | content/presentation/bindings | - | Which package asset fills which slot on which subject: the icon on an item, the sprite on a unit or a tile, the banner on a faction, for the package's own content and for the base game's alike. |
progress | Ready to use | content/player/progress | - | Player progression records: achievements, safe difficulty profiles and run profiles within host rules. |
quest_metadata | Ready to use | content/narrative/quest_metadata | modding_quest_metadata_report | Quest metadata records, keyed by quest_id rather than id. |
quest_plans | Ready to use | content/narrative/quest_plans | modding_quest_plan_report | Quest plans as records: the arcs, quests, objectives and endings a package writes out and the game builds into quests of its own. |
quests | Checked, not installed yet | content/narrative/quests | - | Authored quest resources, loaded by path rather than parsed as records. |
recipes | Ready to use | content/economy/recipes | - | Crafting recipes: what is consumed, what comes out, where the work happens, what it costs and how likely it is to fail. |
relationships | Ready to use | content/companion/relationships | - | Companion templates, bounded relationship policies, recruitment terms and declarative bonds. |
scenes | Ready to use | content/narrative/scenes | modding_scene_report | Staged scenes: the room, who is standing in it and the beats that play out in order. |
screens | Ready to use | content/ui/screens | modding_screen_report | Screen layouts: which sections stand in which panel of a screen the game already has, and in what order. |
settings | Ready to use | content/settings | modding_settings_report | Options a player can change, declared rather than drawn. |
themes | Ready to use | content/assets/themes | modding_theme_report | Interface palettes as tokens: named colours, sizes and spacings, with the pairs whose contrast a player has to be able to read. |
vendor_inventories | Ready to use | content/world/vendor_inventories | modding_vendor_inventory_report | Vendor stock tables. |
world_generation | Ready to use | content/world/generation | - | Data-only generation profiles: weighted host-owned biome and placement selections. |
- Ready to use — published shape, the game installs your records, an example exists, and your ids survive a save and a reload.
- Validated, nothing consumes it yet — the loader reads and checks the family, but no player-facing package surface consumes it. Audio and font packages are in this rung: a clean report does not make a silent cue or an unused face appear in the game.
- Checked, not installed yet — the game recognises the family and checks your file, and nothing puts it into a running game. A package targeting one passes every check and loads nothing. It is on the page on purpose: the alternative is somebody spending an evening on it and never learning that they did nothing wrong.
Settled in full, with no family to carry them, so no manifest can ask:
| Would be | Where it would go | What it is |
|---|---|---|
feature_extension | no folder yet | A package that is a feature rather than content. What a mod can never do is the honest reading of this one. |
These are not refusals. What a mod can never do is that list.
The families, one by one
Every family the build knows, what it accepts, and one record you can copy.
- Required fields are the ones a record cannot install without.
- The values column is the shape the loader checks, not a suggestion.
- Where a family says so, a field the table does not name is refused along with the record it is in. A typo in a field name is a record that does not load, and the family's report says so by name.
Each entry also says what happens to a save the day a player uninstalls you:
| Answer | What the save does |
|---|---|
preserve_inert_reference | The id stays, resolves to nothing, and is reported. What the player had is still named. |
substitute_placeholder | A stable engine-owned stand-in answers so the world still loads, and the original id is kept alongside it. |
drop_optional_presentation | The reference is dropped because nothing depends on it. Only a presentation family may declare this. |
Nothing here is typed by hand. The fields come from each family's published schema. Every example is one of the packages the framework's own test matrix runs against the loader, re-namespaced to northern_reach, and checked against its schema again on the way onto this page: an example the game would refuse fails the build.
The world and the places in it
5 families: buildings, caravans, maps, pois, world_generation.
buildings
Building and settlement-service definitions: label, colour, role and a reused interior.
- Status Ready to use
- Walked through in A building service
- Folder
content/world/buildings - Records addressed by the
idfield - Report
modding_building_report - If a player uninstalls you
substitute_placeholder. A settlement with a hole where a service was is unplayable; a visibly missing building is legible and recoverable.
Fields. A field this table does not name is refused along with the record.
| Field | Required | Values |
|---|---|---|
id | yes | text, id |
role | yes | string |
color | - | any |
description_key | - | string |
interior | - | text, id |
interior_service | - | text, id |
label | - | string |
label_key | - | string |
map_binding | - | text, id |
services | - | list |
settlement | - | text, id |
tier | - | integer 0 to 5 |
In a file called buildings.json, or any other .json name you like in that folder:
a buildings record, under content/world/buildings
[
{
"id": "northern_reach:guildhall",
"label": "Guild Hall",
"role": "reeve",
"color": {
"r": 0.4,
"g": 0.34,
"b": 0.26
},
"interior_service": "moot_hall"
}
]caravans
Trade routes a caravan runs: where it starts, where it is going, what it carries, how well it is guarded and how fast it moves.
- Status Ready to use
- Folder
content/logistics/caravans - Records addressed by the
idfield - Report
modding_caravan_report - If a player uninstalls you
preserve_inert_reference. A route whose package is gone keeps its name and carries nothing. Substituting a destination would send a player's goods somewhere they never agreed to.
Fields. A field this table does not name is refused along with the record.
| Field | Required | Values |
|---|---|---|
id | yes | text, id |
origin_settlement_id | yes | string |
destination_settlement_id | yes | string |
cargo_type | - | string |
cargo_value | - | integer, 0 or more |
daily_progress | - | number 0.01 to 1.0 |
display_name | - | string |
group_archetype_id | - | string |
guard_strength | - | none, light, medium, heavy |
initial_spawn_offset_days | - | integer 0 to 30 |
In a file called caravans.json, or any other .json name you like in that folder:
a caravans record, under content/logistics/caravans
[
{
"id": "northern_reach:salt_road_run",
"display_name": "Salt Road Run",
"origin_settlement_id": "valdmere",
"destination_settlement_id": "old_seat",
"cargo_type": "salt",
"cargo_value": 240,
"guard_strength": "light",
"daily_progress": 0.08,
"initial_spawn_offset_days": 3
}
]maps
Layouts a package draws in Tiled, named by id: the file, where it sits in the world, and the language key it is known by. The index is the record; the layout beside it is read as data and never as a resource, normalised into canonical form, checked against the published kit and assembled before anything is placed. A layout that refuses places nothing at all, because half a map is a region the player walks into with no way to know which half arrived.
- Status Ready to use
- Folder
content/world/maps - Records addressed by the
idfield - Report
modding_map_report - If a player uninstalls you
drop_optional_presentation. No save names a layout. A save names the POI a layout moved, and that record keeps its own coordinate, so a withdrawn map costs the arrangement and never the content. Dropping it is what makes the world go back to what its records say instead of holding a position nothing supplies any more.
Fields. A field this table does not name is refused along with the record.
| Field | Required | Values |
|---|---|---|
id | yes | text, id |
file | yes | text, fixed shape |
name_key | yes | string |
world_origin | yes | list of integer -1000000 to 1000000 |
region_id | - | string |
sha256 | - | text, fixed shape |
In a file called maps.json, or any other .json name you like in that folder:
a maps record, under content/world/maps
[
{
"id": "northern_reach:river_bend",
"file": "river_bend.tmj",
"name_key": "MAP_RIVER_BEND",
"world_origin": [
4096,
2560
],
"region_id": "northern_reach:lowlands"
}
]pois
Points of interest placed on the authored world map by coordinate.
- Status Ready to use
- Walked through in A place on the map
- Folder
content/world/pois - Records addressed by the
idfield - Report
modding_poi_report - If a player uninstalls you
preserve_inert_reference. A discovered place stays discovered. Quests and travel history point at the id.
Fields. A field this table does not name is refused along with the record.
| Field | Required | Values |
|---|---|---|
id | yes | text, id |
display_name | yes | string |
awareness_modifier | - | number -1 to 1 |
description | - | string |
discovery_radius | - | number 1 to 10 |
kind | - | integer or string |
linked_quest_id | - | string |
morale_modifier | - | integer -10 to 10 |
narrative_significance_key | - | string |
one_time_loot | - | boolean |
world_position | - | any |
In a file called pois.json, or any other .json name you like in that folder:
a pois record, under content/world/pois
[
{
"id": "northern_reach:drowned_mile",
"kind": "RUINS",
"display_name": "The Drowned Mile",
"description": "A stretch of road the river took back. The milestones still stand, chest deep.",
"world_position": {
"x": 0.0,
"y": 0.0
},
"discovery_radius": 3.0,
"one_time_loot": true,
"morale_modifier": -1,
"awareness_modifier": 0.05,
"narrative_significance_key": "POI_DROWNED_MILE_LORE"
}
]world_generation
Data-only generation profiles: weighted host-owned biome and placement selections.
- Status Ready to use
- Folder
content/world/generation - Records addressed by the
idfield - Report none of its own;
modding_preflight_reportcovers it - If a player uninstalls you
preserve_inert_reference. A save records the selected profile id. If its package is absent, preserving and reporting that id is safer than silently rebuilding the run with the base game's map.
Fields. A field this table does not name is refused along with the record.
| Field | Required | Values |
|---|---|---|
kind | yes | any |
id | yes | text, id |
stream | yes | text, id |
tables | yes | object |
description_key | - | string |
name_key | - | string |
In a file called profiles.json, or any other .json name you like in that folder:
a world_generation record, under content/world/generation
[
{
"kind": "generation_profile",
"id": "northern_reach:ashen_reach",
"stream": "northern_reach:world_stream",
"tables": {
"biomes": [
{
"id": "plains",
"weight": 60
},
{
"id": "forest",
"weight": 30
},
{
"id": "hills",
"weight": 10
}
],
"settlements": [
{
"id": "northern_reach:hamlet",
"weight": 100
}
],
"buildings": [
{
"id": "northern_reach:longhouse",
"weight": 100
}
],
"pois": [
{
"id": "northern_reach:standing_stone",
"weight": 100
}
]
}
}
]People, houses and what they can do
8 families: enemy_archetypes, enemy_variants, group_archetypes, mercenary_templates, noble_houses, npc_definitions, perks, relationships.
enemy_archetypes
Kinds of enemy the strategic layer knows about: tier, which variants they field, where they camp, how large the garrison is and how often they raid.
- Status Ready to use
- Folder
content/threat/enemy_archetypes - Records addressed by the
idfield - Report
modding_enemy_archetype_report - If a player uninstalls you
preserve_inert_reference. A camp still points at what settled it. Substituting would quietly change what is besieging a settlement; naming the absence is legible.
Fields. A field this table does not name is refused along with the record.
| Field | Required | Values |
|---|---|---|
id | yes | text, id |
aggression | - | number 0 to 1 |
behavior_profile_mix | - | list of string |
camp_count | - | integer 0 to 12 |
camp_glyph | - | string |
corruption_chance | - | number 0 to 1 |
description | - | string |
display_name | - | string |
encounter_id | - | string |
garrison_max | - | integer 1 to 60 |
garrison_min | - | integer 1 to 60 |
preferred_camp_biomes | - | list of string |
raid_cooldown_days | - | integer 0 to 30 |
sortie_size_max | - | integer 1 to 30 |
sortie_size_min | - | integer 1 to 30 |
target_preferences | - | list of string |
tier | - | integer 1 to 5 |
variant_ids | - | list of string |
variant_weights | - | list |
In a file called enemy_archetypes.json, or any other .json name you like in that folder:
a enemy_archetypes record, under content/threat/enemy_archetypes
[
{
"id": "northern_reach:salt_reavers",
"display_name": "Salt Reavers",
"description": "Coastal raiders who winter inland and burn what they cannot carry.",
"tier": 2,
"corruption_chance": 0.1,
"preferred_camp_biomes": [
"marsh",
"coast"
],
"camp_count": 3,
"garrison_min": 8,
"garrison_max": 16,
"aggression": 0.4,
"raid_cooldown_days": 5,
"sortie_size_min": 4,
"sortie_size_max": 9,
"target_preferences": [
"caravan",
"settlement"
],
"camp_glyph": "tent"
}
]enemy_variants
Stat blocks for a kind of enemy body on the field: class, tier, the five attributes and the weapon, off-hand and shield ids it carries.
- Status Ready to use
- Folder
content/combat/enemy_variants - Records addressed by the
idfield - Report
modding_enemy_variant_report - If a player uninstalls you
substitute_placeholder. A body already on the field with no stat block cannot be fought. A stable engine-owned stand-in keeps the battle resolvable and the original id is kept beside it.
Fields. A field this table does not name is refused along with the record.
| Field | Required | Values |
|---|---|---|
id | yes | text, id |
archetype_id | yes | string |
agility | - | number 0 to 12 |
ai_behavior | - | string |
aim | - | number 0 to 12 |
constitution | - | number 0 to 12 |
display_name | - | string |
main_weapon_id | - | string |
off_weapon_id | - | string |
shield_id | - | string |
special_ability_description | - | string |
special_ability_name | - | string |
strength | - | number 0 to 12 |
tier | - | integer 1 to 4 |
variant_class | - | soldier, archer, berserker, ranger, finisher |
will | - | number 0 to 12 |
In a file called enemy_variants.json, or any other .json name you like in that folder:
a enemy_variants record, under content/combat/enemy_variants
[
{
"id": "northern_reach:salt_reaver_archer",
"archetype_id": "northern_reach:salt_reavers",
"display_name": "Salt Reaver Archer",
"variant_class": "archer",
"tier": 2,
"strength": 3.0,
"agility": 5.0,
"constitution": 3.5,
"aim": 6.0,
"will": 2.5,
"main_weapon_id": "short_bow",
"ai_behavior": "keeps distance and gives ground rather than trade blows"
}
]group_archetypes
Strategy tunables for a group that lives on the world map: alarm radius, target preference, member counts, supply and retreat thresholds.
- Status Ready to use
- Folder
content/strategic/group_archetypes - Records addressed by the
idfield - Report
modding_group_archetype_report - If a player uninstalls you
substitute_placeholder. The strategic AI has to read a threshold from somewhere. An engine-owned default answers, which is the same thing this family already falls through to.
Fields. A field this table does not name is refused along with the record.
| Field | Required | Values |
|---|---|---|
id | yes | text, id |
aggression | - | string |
alarm_radius_px | - | number 0 to 2000 |
behavior_profile_mix | - | list of string |
captain_archetype_id | - | string |
display_name | - | string |
faction_id | - | string |
kind | - | none, caravan, patrol, army, lair, camp, … |
max_members | - | integer 1 to 60 |
member_archetype_mix | - | list of string |
min_members | - | integer 1 to 60 |
night_aggression_multiplier | - | number 0.25 to 4 |
provisions_max | - | integer 0 to 99 |
retreat_hp_pct | - | number 0 to 1 |
target_preference | - | string |
In a file called group_archetypes.json, or any other .json name you like in that folder:
a group_archetypes record, under content/strategic/group_archetypes
[
{
"id": "northern_reach:pilgrim_column",
"display_name": "Pilgrim Column",
"kind": "nomad",
"faction_id": "",
"target_preference": "wandering",
"aggression": "avoid_player",
"alarm_radius_px": 300.0,
"night_aggression_multiplier": 0.5,
"min_members": 6,
"max_members": 14,
"behavior_profile_mix": [
"coward",
"balanced"
],
"provisions_max": 6,
"retreat_hp_pct": 0.6
}
]mercenary_templates
Hireable mercenary blueprints: name, class, background, starting health and morale, hire cost and the day they first appear.
- Status Ready to use
- Folder
content/character/mercenaries - Records addressed by the
idfield - Report
modding_mercenary_report - If a player uninstalls you
preserve_inert_reference. Mercenaries already hired live in the save as units; only the tavern list loses its blueprint. The id stays named so the roster's origin is still readable.
Fields. A field this table does not name is refused along with the record.
| Field | Required | Values |
|---|---|---|
id | yes | text, id |
display_name | yes | string |
available_from_day | - | integer 0 to 999 |
background | - | string |
class_id | - | string |
hire_cost | - | integer 0 to 9999 |
hp_max | - | integer 1 to 999 |
morale_start | - | integer 1 to 100 |
In a file called mercenaries.json, or any other .json name you like in that folder:
a mercenary_templates record, under content/character/mercenaries
[
{
"id": "northern_reach:ditchwater_sapper",
"display_name": "Ditchwater Sapper",
"class_id": "skirmisher",
"background": "Dug siege trenches for whoever was paying, and stopped asking which side it was.",
"hp_max": 24,
"morale_start": 70,
"hire_cost": 180,
"available_from_day": 6
}
]noble_houses
Noble house seed data: the lord, the heir, which settlements answer to the house, and who it counts as rival or ally.
- Status Ready to use
- Folder
content/politics/noble_houses - Records addressed by the
idfield - Report
modding_noble_house_report - If a player uninstalls you
preserve_inert_reference. A player's standing with a house is recorded against its id. Substituting one would rewrite who they were loyal to without a word.
Fields. A field this table does not name is refused along with the record.
| Field | Required | Values |
|---|---|---|
id | yes | text, id |
alignment | - | pro_crown, pragmatic, isolationist, hostile, neutral, opportunist, … |
ally_house_ids | - | any |
apex_ambition | - | any |
apex_claim_legitimacy | - | any |
captain_initial_opinion | - | integer 0 to 100 |
captain_initial_stance | - | hostile, suspicious, neutral, willing, allied |
castle_id | - | string |
corruption_risk | - | low, moderate, high, critical, unknown |
current_lord_id | - | string |
designer_hooks | - | string |
display_name | - | string |
display_name_key | - | string |
family_npc_ids | - | any |
heir_id | - | string |
house_power_base | - | any |
is_defunct | - | boolean |
is_minor | - | boolean |
lord_state | - | healthy, ailing, corrupted, missing, dead, none |
lore_summary | - | string |
predecessor_house_id | - | string |
rival_house_ids | - | any |
secret_flags | - | any |
settlement_ids | - | any |
steward_npc_id | - | string |
strength | - | dominant, stable, reduced, weakened, vacant, extinct |
In a file called noble_houses.json, or any other .json name you like in that folder:
a noble_houses record, under content/politics/noble_houses
[
{
"id": "northern_reach:house_tidewatch",
"display_name_key": "VALID_NOBLE_HOUSE_MOD_HOUSE_TIDEWATCH",
"display_name": "House Tidewatch",
"strength": "reduced",
"alignment": "isolationist",
"corruption_risk": "moderate",
"is_minor": true,
"lord_state": "ailing",
"captain_initial_stance": "suspicious",
"captain_initial_opinion": 35,
"apex_ambition": 20,
"apex_claim_legitimacy": 15,
"house_power_base": 25,
"lore_summary": "Kept the coast road open for two hundred years and was paid in salt for it."
}
]npc_definitions
Named NPC definitions.
- Status Ready to use
- Walked through in A merchant who stocks it
- Folder
content/narrative/npcs - Records addressed by the
idfield - Report
modding_npc_report - If a player uninstalls you
preserve_inert_reference. A named person the world remembers is not the same as one who never existed, and relationships point at the id.
Fields. A field this table does not name is refused along with the record.
| Field | Required | Values |
|---|---|---|
id | yes | text, id |
display_name | yes | string |
faction | - | string or integer |
flag_keys | - | list of string |
is_vendor | - | boolean |
kill_reputation_delta | - | integer -100 to 100 |
location_afternoon | - | text, id |
location_evening | - | text, id |
location_morning | - | text, id |
lore_only | - | boolean |
need_quest_ids | - | list of string |
offers_quest_ids | - | list of string |
personal_need | - | string |
personality_tags | - | list of string |
portrait_id | - | text, id |
price_multiplier | - | number 0.1 to 10 |
role | - | string or integer |
settlement_id | - | text, id |
short_bio | - | string |
starts_hidden | - | boolean |
title | - | string |
vendor_inventory_id | - | text, id |
voice_tone | - | string |
In a file called npcs.json, or any other .json name you like in that folder:
a npc_definitions record, under content/narrative/npcs
[
{
"id": "northern_reach:wandering_herbalist",
"display_name": "Wandering Herbalist",
"title": "Travelling Healer",
"role": "NONE",
"settlement_id": "old_seat",
"is_vendor": true,
"vendor_inventory_id": "northern_reach:herb_stall",
"personality_tags": [
"gentle",
"secretive"
]
}
]perks
Declarative perks: triggers and effects, with no script handler.
- Status Ready to use
- Folder
content/character/perks - Records addressed by the
idfield - Report
modding_perk_report - If a player uninstalls you
preserve_inert_reference. A character keeps what they earned. Dropping a perk silently rewrites a build the player spent a run on.
Fields. A field this table does not name is refused along with the record.
| Field | Required | Values |
|---|---|---|
id | yes | text, id |
skill | yes | text, fixed shape |
name_key | yes | string |
kind | yes | road, junction, lateral, capstone |
col | yes | integer 0 to 4 |
row | yes | integer 1 to 7 |
level | yes | integer 1 to 300 |
description_key | - | string |
icon | - | text, id |
parents | - | list of string |
side | - | L, R |
In a file called perks.json, or any other .json name you like in that folder:
a perks record, under content/character/perks
[
{
"id": "northern_reach:stoneskin",
"skill": "armour",
"name_key": "PERK_STONESKIN",
"description_key": "PERK_STONESKIN_DESC",
"kind": "lateral",
"side": "L",
"col": 0,
"row": 7,
"level": 100,
"parents": [],
"icon": "northern_reach:stoneskin_icon"
}
]relationships
Companion templates, bounded relationship policies, recruitment terms and declarative bonds.
- Status Ready to use
- Folder
content/companion/relationships - Records addressed by the
idfield - Report none of its own;
modding_preflight_reportcovers it - If a player uninstalls you
preserve_inert_reference. A companion's relationship seed remains attached to its id so removing a package cannot silently reset trust, grievances or departure history.
Fields. This family holds more than one kind of record, and each kind is checked against its own list. A field this table does not name is refused along with the record.
A record with kind: bond:
| Field | Required | Values |
|---|---|---|
id | yes | text, id |
kind | yes | bond |
relation | yes | string |
subject | yes | string |
target | yes | string |
initial_regard | yes | integer -100 to 100 |
policy | yes | string |
description_key | - | string |
name_key | - | string |
note_key | - | string |
A record with kind: companion_template:
| Field | Required | Values |
|---|---|---|
id | yes | text, id |
kind | yes | companion_template |
unit | yes | string |
recruit | yes | string |
loyalty | yes | string |
description_key | - | string |
leave_below | - | integer -100 to 100 |
name_key | - | string |
starting_regard | - | integer -100 to 100 |
A record with kind: recruit_terms:
| Field | Required | Values |
|---|---|---|
id | yes | text, id |
kind | yes | recruit_terms |
means | yes | string |
cost | - | integer 0 to 1000000 |
description_key | - | string |
name_key | - | string |
requires_bond | - | string |
A record with kind: relationship_policy:
| Field | Required | Values |
|---|---|---|
id | yes | text, id |
kind | yes | relationship_policy |
relation_kinds | yes | list of string |
bounds | yes | object |
bands | yes | list of object |
decay_per_day | - | integer 0 to 20 |
description_key | - | string |
max_change_per_event | - | integer 1 to 50 |
name_key | - | string |
In a file called relationships.json, or any other .json name you like in that folder:
a relationships record, under content/companion/relationships
[
{
"id": "northern_reach:marsh_policy",
"kind": "relationship_policy",
"relation_kinds": [
"comrade",
"feud",
"sworn"
],
"bounds": {
"floor": -100,
"ceiling": 100
},
"bands": [
{
"name": "hostile",
"from": -100,
"to": -61
},
{
"name": "cold",
"from": -60,
"to": -31
},
{
"name": "wary",
"from": -30,
"to": -11
},
{
"name": "neutral",
"from": -10,
"to": 10
},
{
"name": "warm",
"from": 11,
"to": 40
},
{
"name": "trusted",
"from": 41,
"to": 70
},
{
"name": "devoted",
"from": 71,
"to": 100
}
],
"max_change_per_event": 12,
"decay_per_day": 1
},
{
"id": "northern_reach:hire_terms",
"kind": "recruit_terms",
"means": "hire",
"cost": 240
},
{
"id": "northern_reach:marsh_companion",
"kind": "companion_template",
"unit": "northern_reach:marsh_unit",
"recruit": "northern_reach:hire_terms",
"loyalty": "northern_reach:marsh_policy",
"starting_regard": 8,
"leave_below": -60
}
]Things, trade and the numbers behind a fight
5 families: combat_balance, goods, items, recipes, vendor_inventories.
combat_balance
Bounded combat balance values that tune existing resolver parameters without adding rules, operations or mechanics.
- Status Ready to use
- Folder
content/combat/balance - Records addressed by the
idfield - Report
modding_combat_balance_report - If a player uninstalls you
substitute_placeholder. Balance is host-owned definition data rather than player inventory. When its package is absent, the stable canonical combat balance answers and the next load does not carry a second tuning save path.
Fields. A field this table does not name is refused along with the record.
| Field | Required | Values |
|---|---|---|
id | yes | text, id |
values | yes | object |
In a file called balance.json, or any other .json name you like in that folder:
a combat_balance record, under content/combat/balance
[
{
"id": "northern_reach:coastal_balance",
"values": {
"zone_penalty_head": 22,
"crit_damage_mult": 1.75,
"friendly_fire_redirect_chance": 0.2
}
}
]goods
Tradeable goods and their economic properties.
- Status Ready to use
- Walked through in A good to sell
- Folder
content/economy/goods - Records addressed by the
idfield - Report
modding_goods_report - If a player uninstalls you
preserve_inert_reference. A stockpile that names a good nobody supplies is honest. Substituting one would silently change what a player owns.
Fields. A field this table does not name is refused along with the record.
| Field | Required | Values |
|---|---|---|
id | yes | text, id |
base_price | - | integer 0 to 1000000 |
category | - | string or integer |
daily_consumption_class | - | string |
decay_tier | - | string or integer |
display_name | - | string |
display_name_key | - | string |
linked_item_id | - | string |
production_class | - | string |
tags | - | list of string |
weight | - | number 0 to 10000 |
In a file called goods.json, or any other .json name you like in that folder:
a goods record, under content/economy/goods
[
{
"id": "northern_reach:spiced_wine",
"display_name": "Spiced Wine",
"category": "consumable",
"decay_tier": "preserved",
"base_price": 12,
"weight": 0.5,
"tags": [
"luxury",
"drink"
]
}
]items
Inventory items, including weapons and armour.
- Status Ready to use
- Walked through in An item to carry
- Folder
content/inventory/items - Records addressed by the
idfield - Report
modding_items_report - If a player uninstalls you
preserve_inert_reference. The stash keeps naming what was in it, which is the only reason reinstalling gives it back.
Fields. A field this table does not name is refused along with the record.
| Field | Required | Values |
|---|---|---|
id | yes | text, id |
base_value | - | integer, 0 or more |
category | - | string or integer |
condition | - | number 0 to 1 |
description_key | - | string |
display_name | - | string |
display_name_key | - | string |
icon_id | - | text, id |
max_stack | - | integer, 1 or more |
quality_tier | - | integer, 0 or more |
rarity | - | string or integer |
sellable | - | boolean |
tags | - | list of string |
weight | - | number, 0 or more |
In a file called items.json, or any other .json name you like in that folder:
a items record, under content/inventory/items
[
{
"id": "northern_reach:spiced_wine_bottle",
"display_name": "Bottle of Spiced Wine",
"category": "CONSUMABLE",
"rarity": "UNCOMMON",
"weight": 0.5,
"max_stack": 10,
"base_value": 14,
"sellable": true,
"tags": [
"drink",
"luxury"
]
}
]recipes
Crafting recipes: what is consumed, what comes out, where the work happens, what it costs and how likely it is to fail.
- Status Ready to use
- Folder
content/economy/recipes - Records addressed by the
idfield - Report none of its own;
modding_preflight_reportcovers it - If a player uninstalls you
preserve_inert_reference. Nothing in a save names a recipe: a save names the item a craft produced, and that item's own family answers for it. What a withdrawn recipe leaves is a station that no longer offers something, which is reported rather than repaired, because inventing a replacement recipe would put an item in a player's hands that no package supplies.
Fields. A field this table does not name is refused along with the record.
| Field | Required | Values |
|---|---|---|
id | yes | text, id |
inputs | yes | object |
craft_tax_rate | - | number 0.0 to 1.0 |
description_key | - | string |
display_name_key | - | string |
enhancer_policy | - | none, consumed, preserved, damaged, transformed |
failure_policy | - | none, lose_fuel, lose_catalyst, lose_all, damaged_output |
gold_cost | - | integer 0 to 9999 |
legality | - | legal, licensed, suspicious, forbidden, black_market |
optional_enhancer | - | string |
output_count | - | integer 1 to 99 |
output_good_id | - | string |
output_item_id | - | string |
quality_policy | - | fixed, material_inputs, crafter_skill, enhancer, materials_and_crafter |
recipe_kind | - | normal, equipment, special, enhancement |
requires_perk_id | - | string |
requires_service | - | string |
station_fee | - | integer 0 to 9999 |
success_chance | - | number 0.0 to 1.0 |
time_cost_minutes | - | integer 1 to 60 |
tools_required | - | list of string |
In a file called recipes.json, or any other .json name you like in that folder:
a recipes record, under content/economy/recipes
[
{
"id": "northern_reach:salt_cured_ration",
"display_name_key": "VALID_RECIPE_MOD_SALT_CURED_RATION",
"recipe_kind": "normal",
"inputs": {
"salt": 1,
"dried_meat": 2
},
"output_item_id": "northern_reach:salt_cured_ration",
"output_count": 3,
"requires_service": "kitchen",
"gold_cost": 4,
"time_cost_minutes": 20,
"success_chance": 0.95,
"failure_policy": "lose_fuel",
"quality_policy": "material_inputs",
"legality": "legal"
}
]vendor_inventories
Vendor stock tables.
- Status Ready to use
- Walked through in A merchant who stocks it
- Folder
content/world/vendor_inventories - Records addressed by the
idfield - Report
modding_vendor_inventory_report - If a player uninstalls you
drop_optional_presentation. Stock regenerates. A vendor with an empty shelf is a legible state and nothing else points at the row.
Fields. A field this table does not name is refused along with the record.
| Field | Required | Values |
|---|---|---|
id | yes | text, id |
display_name | yes | string |
entries | yes | list of object |
buyback_rate | - | number 0 to 1 |
description | - | string |
global_price_multiplier | - | number, 0 or more |
npc_id | - | string |
In a file called vendors.json, or any other .json name you like in that folder:
a vendor_inventories record, under content/world/vendor_inventories
[
{
"id": "northern_reach:herb_stall",
"display_name": "Herbalist's Stall",
"npc_id": "northern_reach:wandering_herbalist",
"global_price_multiplier": 1.0,
"buyback_rate": 0.4,
"entries": [
{
"item_id": "northern_reach:spiced_wine_bottle",
"kind": "consumable",
"daily_stock": 3,
"price_multiplier": 1.1,
"restock_days": 2,
"min_standing": 0
}
]
}
]Story, quests and the words a player reads
9 families: dialogue, locales, lore, mr_lines, progress, quest_metadata, quest_plans, quests, scenes.
dialogue
Conversations as records: the graph, its beats and the choices standing on them. Three kinds share the family and kind says which. A package writes the conversation, never the words — every line is a locale key — and every condition and consequence is a check or a command this build already performs.
- Status Ready to use
- Folder
content/narrative/dialogue - Records addressed by the
idfield - Report
modding_dialogue_report - If a player uninstalls you
preserve_inert_reference. A save records what a conversation did — a quest moved, an item changed hands, a truth learned — never the conversation itself, which is over by the time anything is written. When the package leaves, the consequences stay because they were never dialogue's to hold, and the conversation simply is not offered again. Substituting one would put words in a character's mouth that their author never wrote.
Fields. This family holds more than one kind of record, and each kind is checked against its own list. A field this table does not name is refused along with the record.
A record with kind: dialogue_graph:
| Field | Required | Values |
|---|---|---|
kind | yes | dialogue_graph |
id | yes | string |
entry | yes | string |
description_key | - | text, fixed shape |
name_key | - | text, fixed shape |
speaker | - | string |
A record with kind: dialogue_node:
| Field | Required | Values |
|---|---|---|
kind | yes | dialogue_node |
id | yes | string |
graph | yes | any |
voice | yes | line |
speaker | yes | string |
line_key | yes | any |
conditions | - | any |
description_key | - | any |
name_key | - | any |
reveals | - | any |
A record with kind: dialogue_node:
| Field | Required | Values |
|---|---|---|
kind | yes | dialogue_node |
id | yes | string |
graph | yes | any |
voice | yes | narration |
line_key | yes | any |
conditions | - | any |
description_key | - | any |
name_key | - | any |
reveals | - | any |
A record with kind: dialogue_node:
| Field | Required | Values |
|---|---|---|
kind | yes | dialogue_node |
id | yes | string |
graph | yes | any |
voice | yes | end |
conditions | - | any |
description_key | - | any |
line_key | - | any |
name_key | - | any |
reveals | - | any |
A record with kind: dialogue_option:
| Field | Required | Values |
|---|---|---|
kind | yes | dialogue_option |
id | yes | string |
node | yes | string |
label_key | yes | text, fixed shape |
goto | yes | string |
commands | - | list of any |
conditions | - | list of any |
description_key | - | text, fixed shape |
name_key | - | text, fixed shape |
In a file called gate_toll.json, or any other .json name you like in that folder:
a dialogue record, under content/narrative/dialogue
[
{
"kind": "dialogue_graph",
"id": "northern_reach:gate_toll",
"entry": "northern_reach:gate_open",
"speaker": "gate_warden",
"name_key": "TALK_PACK_GATE_TOLL_NAME",
"description_key": "TALK_PACK_GATE_TOLL_DESC"
},
{
"kind": "dialogue_node",
"id": "northern_reach:gate_open",
"graph": "northern_reach:gate_toll",
"voice": "line",
"speaker": "gate_warden",
"line_key": "TALK_PACK_GATE_OPEN"
},
{
"kind": "dialogue_option",
"id": "northern_reach:opt_pay_the_toll",
"node": "northern_reach:gate_open",
"label_key": "TALK_PACK_OPT_PAY",
"goto": "northern_reach:gate_paid",
"commands": [
{
"step": "pay_coin",
"coin": 25
},
{
"step": "change_relation",
"subject": "player",
"target": "gate_warden",
"delta": 4
}
]
}
]The fixture holds 12 records; 3 are shown.
locales
The words a player reads, as CSV with a column per language: the package's own keys, and the game's own where the package declares the override.
- Status Ready to use
- Folder
content/locale - Report
modding_locale_report - If a player uninstalls you
preserve_inert_reference. A save names the record, never the words for it, so a package withdrawn between sessions costs a translation rather than a thing. The key is kept and shows itself, which reads as a missing translation instead of as content that vanished.
Columns. A column this table does not name is refused along with the record.
| Column | Required | Values |
|---|---|---|
key | yes | text, id |
en | - | string |
In a file called words.csv. The file name is half the address and the row's own id is the other half, so two files may hold the same row id without a collision:
a locales file
key,en,it
word_pack:GOOD_FROST_SALT,Frost Salt,Sale di Brinalore
Codex entries: places, people, events, customs and things a world tells stories about. A loaded entry is not a known entry.
- Status Ready to use
- Folder
content/narrative/lore - Records addressed by the
idfield - Report
modding_lore_report - If a player uninstalls you
preserve_inert_reference. A codex entry a player read is something they saw. Dropping it erases part of what they know about a world they spent time in, and quests and other entries point at the id.
Fields. A field this table does not name is refused along with the record.
| Field | Required | Values |
|---|---|---|
id | yes | text, id |
category | yes | place, person, event, custom, thing |
title_key | yes | string |
body_key | yes | string |
chronology_index | - | integer |
citation_key | - | string |
content_warnings | - | list of string |
era | - | string |
relates_to | - | list of string |
requires | - | list of string |
reveals | - | list of string |
In a file called lore.json, or any other .json name you like in that folder:
a lore record, under content/narrative/lore
[
{
"id": "northern_reach:the_drowned_causeway",
"category": "place",
"title_key": "STORY_PACK_DROWNED_CAUSEWAY_TITLE",
"body_key": "STORY_PACK_DROWNED_CAUSEWAY_BODY",
"era": "before_the_mark",
"citation_key": "STORY_PACK_CAUSEWAY_CITATION"
},
{
"id": "northern_reach:the_tide_watch",
"category": "custom",
"title_key": "STORY_PACK_TIDE_WATCH_TITLE",
"body_key": "STORY_PACK_TIDE_WATCH_BODY",
"relates_to": [
"northern_reach:the_drowned_causeway"
]
}
]mr_lines
Structured medieval-realism lines, addressed by file and row id rather than by a JSON id field.
- Status Ready to use
- Folder
content/medieval_realism/structured - Report
modding_mr_lines_report - If a player uninstalls you
drop_optional_presentation. A flavour line nothing refers to. Dropping it costs nothing and keeping it would show an empty string.
Columns. A column this table does not name is allowed, and has to be a plain value rather than a list or an object.
| Column | Required | Values |
|---|---|---|
line_id | yes | text, id |
mechanical_hint | - | string |
public_label | - | string |
public_line | - | string |
public_summary_line | - | string |
speaker | - | string |
tone | - | string |
voice | - | string |
Every record needs at least one of public_line, public_label, public_summary_line.
These names are refused outright, whatever they hold: unsafe_example, unsafe_reason, audit_note, expected_block_reason, script, scene, resource_path. Some are the audit columns the people making the game write with and the rest are the code-shaped names What a mod can never do refuses everywhere. A record carrying one is refused rather than quietly cleaned up.
In a file called MR_L03_TAVERN_INN_LINES.csv. The file name is half the address and the row's own id is the other half, so two files may hold the same row id without a collision:
a mr_lines file
line_id,tavern_state,speaker_role,tone,public_line,mechanical_hint
northern_reach:tavern_extra_01,evening_rush,innkeeper,welcoming,"Warm bread and a clean cup if you've the coin for it, friend.",food_onlyprogress
Player progression records: achievements, safe difficulty profiles and run profiles within host rules.
- Status Ready to use
- Folder
content/player/progress - Records addressed by the
idfield - Report none of its own;
modding_preflight_reportcovers it - If a player uninstalls you
preserve_inert_reference. Earned achievements and selected run profiles remain named when their package is absent; silently dropping them would rewrite player history or the run contract.
Fields. This family holds more than one kind of record, and each kind is checked against its own list. A field this table does not name is refused along with the record.
A record with kind: achievement:
| Field | Required | Values |
|---|---|---|
id | yes | text, id |
kind | yes | achievement |
name_key | yes | string |
description_key | yes | string |
condition | yes | list of object |
campaign | - | string |
hidden | - | boolean |
icon | - | banner, blade, coin, crown, hand, road, … |
A record with kind: difficulty_profile:
| Field | Required | Values |
|---|---|---|
id | yes | text, id |
kind | yes | difficulty_profile |
name_key | yes | string |
modifiers | yes | object |
campaign | - | string |
description_key | - | string |
permadeath | - | boolean |
A record with kind: run_profile:
| Field | Required | Values |
|---|---|---|
id | yes | text, id |
kind | yes | run_profile |
name_key | yes | string |
difficulty | yes | string |
campaign | - | string |
description_key | - | string |
seed_stream | - | string |
starting_origin | - | string |
In a file called progress.json, or any other .json name you like in that folder:
a progress record, under content/player/progress
[
{
"id": "northern_reach:hard_tide",
"kind": "difficulty_profile",
"name_key": "DIFF_HARD_TIDE",
"description_key": "DIFF_HARD_TIDE_DESC",
"modifiers": {
"enemy_strength_percent": 140,
"coin_gain_percent": 80
},
"permadeath": true
},
{
"id": "northern_reach:tide_run",
"kind": "run_profile",
"name_key": "RUN_TIDE",
"description_key": "RUN_TIDE_DESC",
"difficulty": "northern_reach:hard_tide",
"seed_stream": "tide"
},
{
"id": "northern_reach:held_tide",
"kind": "achievement",
"name_key": "ACH_HELD_TIDE",
"description_key": "ACH_HELD_TIDE_DESC",
"condition": [
{
"when": "days_survived",
"count": 30
}
],
"icon": "tower"
}
]quest_metadata
Quest metadata records, keyed by quest_id rather than id.
- Status Ready to use
- Folder
content/narrative/quest_metadata - Records addressed by the
quest_idfield - Report
modding_quest_metadata_report - If a player uninstalls you
preserve_inert_reference. Describes quests that may still be tombstoned themselves; dropping it would leave them undescribed rather than absent.
Fields. A field this table does not name is refused along with the record.
| Field | Required | Values |
|---|---|---|
quest_id | yes | text, id |
arc_id | - | text, id |
choice_valence | - | string |
consequence_hooks | - | list of string |
difficulty_band | - | string |
faction_context | - | string |
failure_consequence | - | string |
moral_pressure | - | string |
notification_policy | - | string |
novelty_tags | - | list of string |
player_model_tags | - | list of string |
quest_role | - | string |
replay_suppression_key | - | string |
reward_intent | - | string |
story_turn_role | - | string |
urgency_band | - | string |
world_reason | - | string |
In a file called quests.json, or any other .json name you like in that folder:
a quest_metadata record, under content/narrative/quest_metadata
[
{
"quest_id": "northern_reach:tavern_errand",
"world_reason": "settlement_shortage",
"quest_role": "recovery",
"difficulty_band": "standard",
"urgency_band": "soon"
}
]quest_plans
Quest plans as records: the arcs, quests, objectives and endings a package writes out and the game builds into quests of its own. The record form of a quest, next to the authored resources rather than in place of them.
- Status Ready to use
- Folder
content/narrative/quest_plans - Records addressed by the
idfield - Report
modding_quest_plan_report - If a player uninstalls you
preserve_inert_reference. A plan builds the same quests the game runs, so a withdrawn one leaves the same hole an authored one would. Progress is the one thing a player cannot rebuild: an unresolvable quest is reported and never quietly completed or deleted.
Fields. This family holds more than one kind of record, and each kind is checked against its own list. A field this table does not name is refused along with the record.
A record with kind: arc:
| Field | Required | Values |
|---|---|---|
kind | yes | any |
id | yes | text, id |
stages | yes | list |
description_key | - | text, fixed shape |
name_key | - | text, fixed shape |
A record with kind: ending:
| Field | Required | Values |
|---|---|---|
kind | yes | any |
id | yes | text, id |
arc | yes | string |
requires | yes | list |
description_key | - | text, fixed shape |
epilogue_key | - | text, fixed shape |
name_key | - | text, fixed shape |
A record with kind: objective:
| Field | Required | Values |
|---|---|---|
kind | yes | any |
id | yes | text, id |
quest | yes | string |
goal | yes | collect_item, defeat_unit, defend_place, deliver_item, earn_coin, escort_unit, … |
count | - | integer 1 to 999 |
description_key | - | text, fixed shape |
name_key | - | text, fixed shape |
optional_step | - | boolean |
target | - | string |
A record with kind: quest:
| Field | Required | Values |
|---|---|---|
kind | yes | any |
id | yes | text, id |
name_key | yes | text, fixed shape |
giver | yes | string |
objectives | yes | list |
deadline_days | - | integer 1 to 3650 |
description_key | - | text, fixed shape |
forfeits | - | list of any |
in_arc | - | string |
rewards | - | list of any |
In a file called the_long_winter.json, or any other .json name you like in that folder:
a quest_plans record, under content/narrative/quest_plans
[
{
"kind": "arc",
"id": "northern_reach:the_long_winter",
"name_key": "northern_reach.arc.the_long_winter.name",
"stages": [
"northern_reach:bring_the_grain"
]
},
{
"kind": "quest",
"id": "northern_reach:bring_the_grain",
"name_key": "northern_reach.quest.bring_the_grain.name",
"description_key": "northern_reach.quest.bring_the_grain.description",
"giver": "northern_reach:the_reeve",
"in_arc": "northern_reach:the_long_winter",
"objectives": [
"northern_reach:gather_grain",
"northern_reach:pay_the_toll"
],
"deadline_days": 30,
"rewards": [
{
"grant": "receive_coin",
"coin": 250
},
{
"grant": "give_item",
"item": "northern_reach:reeves_seal",
"count": 1
}
]
},
{
"kind": "objective",
"id": "northern_reach:gather_grain",
"quest": "northern_reach:bring_the_grain",
"goal": "collect_item",
"target": "northern_reach:winter_grain",
"count": 40,
"name_key": "northern_reach.objective.gather_grain.name"
}
]The fixture holds 5 records; 3 are shown.
quests
Authored quest resources, loaded by path rather than parsed as records. A package that wants a quest of its own writes quest_plans.
- Status Checked, not installed yet
- Folder
content/narrative/quests - Report none of its own;
modding_preflight_reportcovers it - If a player uninstalls you
preserve_inert_reference. Progress is the one thing a player cannot rebuild. An unresolvable quest is reported and never quietly completed or deleted.
This family is loaded by path rather than read as records, and a package cannot ship the file it wants; see What a mod can never do. Write quest_plans instead.
scenes
Staged scenes: the room, who is standing in it and the beats that play out in order. The only content that takes the controls away from the player, which is why a sequence that never reaches end is refused rather than played. Beats and props are names from published lists; nothing here loads a Godot scene, a resource or a script.
- Status Ready to use
- Folder
content/narrative/scenes - Records addressed by the
idfield - Report
modding_scene_report - If a player uninstalls you
preserve_inert_reference. A save records what a scene did — a name chosen, a quest moved, a door opened — never that it was watched. It is played and finished inside one session, so a package leaving costs the scene and never a consequence of having seen it. Substituting one would stage something the author never wrote under the name of something they did.
Fields. A field this table does not name is refused along with the record.
| Field | Required | Values |
|---|---|---|
id | yes | text, id |
beats | yes | list of any |
cast | - | list of any |
name_key | - | any |
stage | - | object |
where | - | string |
In a file called scenes.json, or any other .json name you like in that folder:
a scenes record, under content/narrative/scenes
[
{
"id": "northern_reach:tollhouse_wake",
"name_key": "STAGE_PACK_TOLLHOUSE_WAKE",
"stage": {
"room": [
880,
600
],
"light": "candle",
"props": [
{
"prop": "bed",
"at": "bedside"
},
{
"prop": "storage",
"at": "east",
"label_key": "STAGE_PACK_BELONGINGS"
},
{
"prop": "light_marker",
"at": "north",
"light": "candle"
},
{
"prop": "walkway",
"at": "bedside",
"to": "door"
}
]
},
"cast": [
{
"actor": "northern_reach:tollkeeper",
"name_key": "STAGE_PACK_TOLLKEEPER",
"sprite": "default",
"at": "bedside"
}
],
"beats": [
{
"beat": "lock_player"
},
{
"beat": "fade_in",
"seconds": 2.0
},
{
"beat": "say",
"actor": "northern_reach:tollkeeper",
"line_key": "STAGE_PACK_WAKE_1"
},
{
"beat": "wait",
"seconds": 0.8
},
{
"beat": "say",
"actor": "northern_reach:tollkeeper",
"line_key": "STAGE_PACK_WAKE_2"
},
{
"beat": "play_cue",
"cue": "northern_reach:door_latch"
},
{
"beat": "walk_to",
"actor": "northern_reach:tollkeeper",
"anchor": "door"
},
{
"beat": "remove_actor",
"actor": "northern_reach:tollkeeper"
},
{
"beat": "show_exit"
},
{
"beat": "unlock_player"
},
{
"beat": "end"
}
]
}
]How it all looks and sounds
7 families: audio, effects, fonts, images, presentation_bindings, screens, themes.
audio
Sounds a package ships, named by id: music, ambience, effects and voice, each pointing at a file inside its own folder and declaring the role that bounds it.
- Status Validated, nothing consumes it yet
- Folder
content/assets/audio - Records addressed by the
idfield - Report
modding_audio_report - If a player uninstalls you
preserve_inert_reference. A save names the subject and never the sound for it, so a package withdrawn between sessions costs an atmosphere rather than a thing. The reference is kept and reported: silence with nothing saying why is the state this family exists to avoid.
Fields. A field this table does not name is refused along with the record.
| Field | Required | Values |
|---|---|---|
id | yes | text, id |
file | yes | text, fixed shape |
codec | yes | ogg, wav |
role | yes | music, ambience, sfx, voice |
duration_seconds | - | number, 0 or more |
sha256 | - | text, fixed shape |
subtitle_key | - | string |
In the file audio.json, which is the one file this folder is read through:
a audio record, under content/assets/audio
[
{
"id": "northern_reach:hearth_hum",
"file": "hearth_hum.wav",
"codec": "wav",
"role": "ambience",
"duration_seconds": 0.25,
"sha256": "23fc92432963f74bd638f38ea9d7ddb475853f022810bdc8b344269bf43d5830"
}
]effects
Weather, dust, sparks and lights, composed out of the templates the host implements. A package names a template and sets parameters inside declared ranges; there is no field anywhere that carries a shader, a scene or a script, which is what lets a stranger's package ask for fire without being able to ask for a program.
- Status Ready to use
- Folder
content/assets/effects - Records addressed by the
idfield - Report
modding_effect_report - If a player uninstalls you
preserve_inert_reference. Nothing in a save names an effect. The air goes back to what the base game draws when the package leaves, which costs a look and nothing a player owns.
Fields. A field this table does not name is refused along with the record.
| Field | Required | Values |
|---|---|---|
id | yes | text, id |
template | yes | dust_drift, lightning_flash, rain_sheet, smoke_column, snow_drift, spark_burst, … |
parameters | - | object |
priority | - | integer 0 to 100 |
In a file called effects.json, or any other .json name you like in that folder:
a effects record, under content/assets/effects
[
{
"id": "northern_reach:moor_drift",
"template": "dust_drift",
"parameters": {
"amount": 64,
"lifetime": 6,
"scale": 2,
"wind": 4,
"colour": "#c8bfa8"
},
"priority": 40
}
]fonts
Typefaces a package ships, named by id: the file, the sizes it renders at, and the languages whose glyphs it carries. Coverage is declared rather than discovered, because a face missing a language's glyphs does not fail — it draws boxes, and the screen reads as a translation somebody left half finished.
- Status Validated, nothing consumes it yet
- Folder
content/assets/fonts - Records addressed by the
idfield - Report
modding_font_report - If a player uninstalls you
preserve_inert_reference. Nothing in a save names a font. The interface goes back to the toolkit's own face when the package leaves, which is the only typeface no author has to have chosen.
Fields. A field this table does not name is refused along with the record.
| Field | Required | Values |
|---|---|---|
id | yes | text, id |
file | yes | text, fixed shape |
format | yes | ttf, otf |
sizes | yes | list of integer 8 to 96 |
covers | yes | list |
fallback_font | yes | string |
glyphs | - | list |
oversampling | - | integer 1 to 4 |
sha256 | - | text, fixed shape |
In the file fonts.json, which is the one file this folder is read through:
a fonts record, under content/assets/fonts
[
{
"id": "northern_reach:plain",
"file": "plain.ttf",
"format": "ttf",
"sizes": [
12,
16,
24
],
"oversampling": 1,
"covers": [
"en"
],
"fallback_font": "Segoe UI",
"sha256": "613b1d24f9f8b0365a94f01214155af8e87ca72aa6633befd5e0ee05b3dc4122"
}
]images
Images a package ships, named by id: icons, portraits and sprite sheets, each pointing at a file inside its own folder.
- Status Ready to use
- Folder
content/assets/images - Records addressed by the
idfield - Report
modding_image_report - If a player uninstalls you
preserve_inert_reference. A save names items and people, never the picture of one, so an image that stops resolving costs a drawing rather than a thing. The reference is kept and reported: substituting another package's art would show a player something no author chose, and dropping the id silently would make the missing icon look like a rendering fault.
Fields. A field this table does not name is refused along with the record.
| Field | Required | Values |
|---|---|---|
id | yes | text, id |
file | yes | text, fixed shape |
format | yes | png, webp |
frame_columns | - | integer 1 to 256 |
frame_rows | - | integer 1 to 256 |
sha256 | - | text, fixed shape |
In the file images.json, which is the one file this folder is read through:
a images record, under content/assets/images
[
{
"id": "northern_reach:salt_pouch",
"file": "salt_pouch.png",
"format": "png"
}
]presentation_bindings
Which package asset fills which slot on which subject: the icon on an item, the sprite on a unit or a tile, the banner on a faction, for the package's own content and for the base game's alike.
- Status Ready to use
- Folder
content/presentation/bindings - Records addressed by the
idfield - Report none of its own;
modding_preflight_reportcovers it - If a player uninstalls you
preserve_inert_reference. A save names the subject — the item, the person, the tile — and never which picture dressed it, so a binding that stops resolving costs a drawing rather than a thing. The reference is kept and reported: the subject goes back to the art the build already had, which is the only answer no author has to have chosen.
Fields. A field this table does not name is refused along with the record.
| Field | Required | Values |
|---|---|---|
id | yes | text, id |
subject | yes | text, id |
kind | yes | building, faction, interface, item, region, tile, … |
slot | yes | ambience, banner, effect, font, icon, portrait, … |
cue | yes | text, id |
alternative_key | - | string |
In a file called bindings.json, or any other .json name you like in that folder:
a presentation_bindings record, under content/presentation/bindings
[
{
"op": "add",
"id": "northern_reach:cloak_icon",
"subject": "wool_cloak",
"kind": "item",
"slot": "icon",
"cue": "northern_reach:cloak_plain",
"alternative_key": "item_wool_cloak_icon_alt"
},
{
"op": "add",
"id": "northern_reach:cloak_icon_winter",
"subject": "wool_cloak",
"kind": "item",
"slot": "icon",
"cue": "northern_reach:cloak_snowy",
"alternative_key": "item_wool_cloak_icon_alt",
"when": {
"season": {
"is": "winter"
}
}
}
]screens
Screen layouts: which sections stand in which panel of a screen the game already has, and in what order. It arranges pieces this build draws and cannot add one — a new screen would need widgets, data sources and click handlers, which together are a programming language.
- Status Ready to use
- Folder
content/ui/screens - Records addressed by the
idfield - Report
modding_screen_report - If a player uninstalls you
preserve_inert_reference. Nothing in a save names a layout. It decides how a page is drawn while the package is loaded and the page goes back to the arrangement this build ships when it leaves, which costs a look and nothing a player owns.
Fields. A field this table does not name is refused along with the record.
| Field | Required | Values |
|---|---|---|
id | yes | text, id |
screen | yes | character_sheet |
panels | yes | object |
In a file called screens.json, or any other .json name you like in that folder:
a screens record, under content/ui/screens
[
{
"id": "northern_reach:character_sheet",
"screen": "character_sheet",
"panels": {
"header": [
"header"
],
"left": [
"vitals",
"combat"
],
"middle": [
"attributes",
"skills"
],
"right": [
"service",
"weight",
"background"
]
}
}
]themes
Interface palettes as tokens: named colours, sizes and spacings, with the pairs whose contrast a player has to be able to read. Never a scene, a script or a resource path.
- Status Ready to use
- Folder
content/assets/themes - Records addressed by the
idfield - Report
modding_theme_report - If a player uninstalls you
preserve_inert_reference. Nothing in a save names a theme. The interface goes back to the toolkit's own colours when the package leaves, which is the only palette no author has to have chosen.
Fields. A field this table does not name is refused along with the record.
| Field | Required | Values |
|---|---|---|
id | yes | text, id |
tokens | yes | object |
contrast_pairs | - | list of object |
In a file called themes.json, or any other .json name you like in that folder:
a themes record, under content/assets/themes
[
{
"id": "northern_reach:dusk",
"tokens": {
"bg_deep": {
"kind": "colour",
"value": "#12131a"
},
"bg_panel": {
"kind": "colour",
"value": "#1b1c26"
},
"bg_panel_hi": {
"kind": "colour",
"value": "#262838"
},
"text": {
"kind": "colour",
"value": "#e8e2d0"
},
"text_bright": {
"kind": "colour",
"value": "#f4efe2"
},
"text_mute": {
"kind": "colour",
"value": "#6f6a80"
},
"border": {
"kind": "colour",
"value": "#7f6ab0"
},
"border_dark": {
"kind": "colour",
"value": "#14151c"
},
"border_soft": {
"kind": "colour",
"value": "#4a4560"
},
"danger": {
"kind": "colour",
"value": "#d4564b"
},
"success": {
"kind": "colour",
"value": "#7fc46a"
},
"warning": {
"kind": "colour",
"value": "#e0b13c"
},
"accent_player": {
"kind": "colour",
"value": "#6f97e8"
},
"accent_enemy": {
"kind": "colour",
"value": "#c94a4a"
}
},
"contrast_pairs": [
{
"text": "#e8e2d0",
"background": "#12131a"
},
{
"text": "#f4efe2",
"background": "#1b1c26"
},
{
"text": "#e8e2d0",
"background": "#262838"
}
]
}
]What the player controls, and what watches them
3 families: adaptive, input_bindings, settings.
adaptive
Profiles that watch how a run is going and suggest a different setting for it. The output is advice and never a change: a profile that could act would be the one thing a package ships able to alter a run without the player or the host asking, and in a diff it would look exactly like a tuning table. The inputs are a published list of in-game aggregates, every one a count or an average the run already keeps about itself.
- Status Ready to use
- Folder
content/player/adaptive - Records addressed by the
idfield - Report
modding_adaptive_report - If a player uninstalls you
drop_optional_presentation. No save names a profile. What a profile touched is a multiplier recomputed from the active mutators every time they change, so a withdrawn package stops being consulted and the run goes back to exactly what the player chose. Nothing is kept, so there is nothing to preserve.
Fields. This family holds more than one kind of record, and each kind is checked against its own list. A field this table does not name is refused along with the record.
A record with kind: adaptive_profile:
| Field | Required | Values |
|---|---|---|
kind | yes | any |
id | yes | text, id |
name_key | yes | text, fixed shape |
policy | yes | enemy_composition, event_cadence, hint_frequency, pace_economy, pace_encounters, weather_variety |
reads | yes | list |
fallback_policy | yes | enemy_composition, event_cadence, hint_frequency, pace_economy, pace_encounters, weather_variety |
confidence_floor_percent | - | integer 1 to 100 |
description_key | - | text, fixed shape |
sample_days | - | integer 1 to 365 |
A record with kind: threshold_set:
| Field | Required | Values |
|---|---|---|
kind | yes | any |
id | yes | text, id |
profile | yes | text, id |
thresholds | yes | list of object |
description_key | - | text, fixed shape |
name_key | - | text, fixed shape |
In a file called adaptive.json, or any other .json name you like in that folder:
a adaptive record, under content/player/adaptive
[
{
"kind": "adaptive_profile",
"id": "northern_reach:hard_roads",
"name_key": "ADAPTIVE_HARD_ROADS",
"description_key": "ADAPTIVE_HARD_ROADS_BODY",
"policy": "pace_encounters",
"reads": [
"battles_won_ratio",
"days_survived",
"party_size"
],
"fallback_policy": "event_cadence",
"confidence_floor_percent": 60,
"sample_days": 14
},
{
"kind": "threshold_set",
"id": "northern_reach:hard_roads_lines",
"profile": "northern_reach:hard_roads",
"name_key": "ADAPTIVE_HARD_ROADS_LINES",
"thresholds": [
{
"signal": "battles_won_ratio",
"above": 80,
"advise": "raise"
},
{
"signal": "battles_won_ratio",
"below": 35,
"advise": "lower"
},
{
"signal": "party_size",
"below": 3,
"advise": "lower"
},
{
"signal": "days_survived",
"above": 60,
"advise": "hold"
}
]
}
]input_bindings
Actions of a package's own, with the key it would like. The player decides: a suggestion that lands on a chord they had already bound loses and the action arrives unbound, because a key that quietly does something else is worse than a key that does nothing.
- Status Ready to use
- Folder
content/input - Records addressed by the
action_idfield - Report
modding_input_report - If a player uninstalls you
preserve_inert_reference. A player's remap stays filed under the action id so reinstalling the package finds the key where they put it. The action itself is taken off the input map when the package leaves, so the binding screen never lists something nothing answers.
Fields. A field this table does not name is refused along with the record.
| Field | Required | Values |
|---|---|---|
action_id | yes | text, id |
label_key | yes | string |
context | yes | exploration, combat, menu, global |
suggested_keyboard | yes | string |
suggested_controller | - | string |
In a file called bindings.json, or any other .json name you like in that folder:
a input_bindings record, under content/input
[
{
"action_id": "northern_reach:call_scout",
"label_key": "KEY_PACK_CALL_SCOUT",
"context": "exploration",
"suggested_keyboard": "f9"
}
]settings
Options a player can change, declared rather than drawn. A package says what the option is, what it may be, and where the value lives; the host builds the control. Nothing that crosses this boundary is a Control or a script, which is the only reason a settings menu can show a stranger's options without running their code.
- Status Ready to use
- Folder
content/settings - Records addressed by the
keyfield - Report
modding_settings_report - If a player uninstalls you
preserve_inert_reference. A stored value stays under its key so reinstalling the package finds the player's choice where they left it. What the package leaves behind is a row in a profile nothing reads, which costs nothing and keeps a choice they made.
Fields. A field this table does not name is refused along with the record.
| Field | Required | Values |
|---|---|---|
key | yes | text, id |
type | yes | bool, int, float, string, enum |
default | yes | any |
scope | yes | installation, campaign, presentation |
label_key | yes | string |
help_key | - | string |
max_length | - | integer 1 to 4096 |
maximum | - | number |
migrates_from | - | text, id |
minimum | - | number |
options | - | list of string |
requires_restart | - | boolean |
secret | - | boolean |
In a file called settings.json, or any other .json name you like in that folder:
a settings record, under content/settings
[
{
"key": "northern_reach:patrol_density",
"type": "int",
"default": 3,
"minimum": 1,
"maximum": 9,
"scope": "campaign",
"label_key": "OPTION_PACK_PATROL_DENSITY",
"help_key": "OPTION_PACK_PATROL_DENSITY_HELP"
},
{
"key": "northern_reach:show_road_names",
"type": "bool",
"default": true,
"scope": "presentation",
"label_key": "OPTION_PACK_SHOW_ROAD_NAMES"
}
]Console shortcuts
These are a shortcut, not a step. Everything below has a place on the Mods screen, which is where the normal path goes and where a player who downloaded your package will look. The console is faster when you are iterating and it is already open, and it exists only in a debug build: a released copy of the game has no console at all, so nothing a package author needs is allowed to live only here.
| Command | The same thing on the Mods screen |
|---|---|
modding_open_folder | the path at the top, and the button beside it |
modding_manifest_report | the state column, and the line under each row |
modding_<family>_report | the counts under each package, and Show details |
modding_preflight_report | Check compatibility |
modding_roots_report, modding_safety_report | Copy report |
modding_reload | No button on the screen. Restarting the game does the same thing and is the only path a released build has; this one saves you the restart while you iterate. It is the single row here that is a convenience rather than a shortcut to something the screen already does. |
Start with modding_manifest_report — does the game see your package, and in what state — then modding_preflight_report for one verdict over everything installed. Then the per-family report.
modding_reload reads your files again without restarting. Edit, alt-tab, type it, look. It rebuilds what nothing is holding — bindings, effects and images — and names every family it left alone, because a run is standing on the records a save can name:
what the game prints back
HELD BY THE RUN items: edited files are read on the next loadmodding_reload all reads everything and is for a title screen with no run going. modding_condition_report prints what the game can currently answer for a when, and what it cannot.
| Command | What it tells you |
|---|---|
modding_roots_report | Data mod roots and fingerprints |
modding_manifest_report | Mod states (active/invalid/skipped/conflict) |
modding_fingerprint_report | Deterministic save fingerprints (save_wired=no) |
modding_safety_report | Security posture (behavior mods off, blocked files) |
modding_preflight_report | GREEN/RED verdict over the active mod set |
modding_packaging_report | Tier 2 PCK/ZIP detection (disabled by default) |
modding_campaign_report | Modding_campaign_report [id] - read-only installed campaigns; with an id, switch to it |
modding_condition_report | What the world publishes for record conditions |
modding_reload | Modding_reload [all] - read the packages again; all only with no run standing |
modding_binding_report | Presentation bindings bound/refused/superseded |
modding_open_folder | Create (if needed) and open the user mods folder |
Offline tools
These tools are for authors and release automation. They are shipped in the public Bonemark source checkout; players installing a released mod do not need Python or Godot. If you are writing a mod, clone the matching build from github.com/tallowgames/Bonemark, keep your working package outside the live player mods folder, and run the commands below from that checkout. The game itself remains the final runtime authority.
Python 3.10 or newer; Godot only for the runtime lane. Keep your package outside the player mods folder and test a copy.
a command line
python tools/modding/new_mod.py --list
python tools/modding/new_mod.py --id my_mod --family goods --family itemsWrites a package that validates before you have typed anything: a filled-in mod.json, one working record per family you asked for, and a README with the next two commands. The records come from the fixtures the build already checks for those families, so a starter that stops working fails the same check the fixture does instead of rotting in a template. It refuses to write inside a folder called mods.
a command line
python tools/quality/validate_mod_data.py --root "<mod-root>"
python tools/quality/validate_mod_data.py --strict --root "<mod-root>"Checks mod.json, family folders, ids, schemas, references, bounds and blocked payloads. --strict turns warnings into failures.
Compare the versioned modding surface
When the game changes, compare the checked-in surface snapshot before updating a package. It contains one digest plus the fields and operations for every public family, so the result says which family changed and why:
a command line
python tools/quality/modding_surface_contract.py --json
python tools/quality/modding_surface_contract.py --before "<previous-build>/game/games/bonemark/modding/data/mod_surface_contract.json" --jsonThe diff is unchanged, additive or breaking. Breaking reasons are explicit: field_removed, required_added, type_changed, default_changed, constraint_changed, operation_removed and root_changed, format_changed, id_field_changed, catalogue_changed, family_removed and contract_major_changed_without_new_id. Compatible changes name field_added, required_removed, operation_added or family_added. The command without --before also audits the checked-in baseline. If the current surface moved, the audit fails until a maintainer reviews the exact before/after digests in tools/quality/modding_surface_contract_changes.json; a breaking change must also record every reason, the decision, the migration note and the reviewer. Promoting an intentionally reviewed snapshot is explicit:
a command line
python tools/quality/modding_surface_contract.py --write-baselineThis is a maintainer/release check; mod authors only need the --before comparison, and players installing a released package do not need to run it.
a command line
python tools/modding/mod_test_host.py --root "<mod-root>" --repo-root "<framework-root>" --jsonReads the manifest, dependencies, compatibility and load order without launching Godot and without executing anything from your package.
It never opens a content file. That is the division of labour — the host answers will these packages load together, the validator answers is this record right — but it means a package the validator refuses can still compose cleanly here. Measured on fourteen deliberately broken packages, the two disagree on every refusal: the host stages all fourteen, refused empty, including one carrying scripts/payload.sh that only the validator finds. Composing is not the same as being safe to install. Run both before you publish. Repeat --root when your test set is split across folders; the host then composes the complete set in one deterministic plan.
a command line
python tools/modding/mod_test_matrix.py --repo-root "<framework-root>" --root "<mod-root>" --godot "<godot-exe>" --require-runtime --report "mod_test_matrix.json"Runs the validator, the test host, every ready-to-use family fixture and the Godot runtime lane. Drop --require-runtime if you have no Godot; the report then says runtime proof was skipped rather than passed.
For a composed set, repeat the option:
a command line
python tools/modding/mod_test_matrix.py --repo-root "<framework-root>" --root "<mod-a>" --root "<mod-b>" --godot "<godot-exe>" --require-runtimeThe report keeps one result per root and adds composed_set for the complete set. That block is where cross-package conflicts, duplicate IDs and dependency errors are proven. A package being clean on its own is not proof that the set will load together. The report also includes surface_contract, the current per-family contract version and digest audit, so a matrix cannot be green while the repository's compatibility snapshot is stale. With --require-runtime, read runtime.reachability.loaded_families and runtime.reachability.reachable_families. The first says that the composed records were loaded; the second is only populated when a real player-facing route in the Godot probe consumed them. runtime.reachability.observations keeps the package, family, record id, route and status for each row, while unreachable lists loaded rows that still have no route. A family can be validated and staged yet still be absent from reachable_families — “loaded” is not the same as “a player can reach it”.
Every command above is checked against the tools themselves when this page is built: the script must exist and every option must be one it accepts.
Every diagnostic code
Never read this in order. It is here so a code you meet in a report is a question with an answer — one short lower-case word like asset_missing. Paste one into the search box at the top of this page.
Some codes belong to machinery a package cannot reach yet. They are here anyway: a code you cannot trigger costs a line you never read, and a code that is missing costs an evening.
| Code | What caused it | What to do |
|---|---|---|
adaptive_brings_a_model | A package trying to load something or reach somewhere. | Remove the path or the address and select a published policy by name. Nothing here loads a file you shipped. |
adaptive_fell_back | A profile that could not reach its floor and used its declared policy. | Nothing to fix, unless it never stops. The profile did not have enough of the signals it reads, so it used the policy it declared as its fallback. |
adaptive_invalid | The record is not one this build would load. | Read the verdict beside the code. It names the single rule the record broke, and every one of them is a field to remove, a field to add or a word to change. |
adaptive_over_budget | A package past a declared ceiling. | The report prints what was measured beside what was allowed. Split the record, or drop the entries past the line: the ceiling is what a screen or a reader can hold, not a suggestion. |
adaptive_text_not_key | A sentence where a catalogue key belongs. | Move the sentence into your locale catalogue and put its key in the field. A field ending _key holds a key; a sentence there ships your package in one language forever. |
adaptive_unknown_step | A policy, a signal or a direction this build does not have. | The name is not on the published list for that table. Pick one that is: the list is short because every entry is something this build already does. |
adaptive_unresolved_reference | A named record nothing supplies. | The id named is one nothing in the composition supplies. Either ship the record it points at, or depend on the package that does. |
assembly_dangling_reference | A stair, door or entrance names something this map does not contain. | Add the object being named to this map, or correct the id. A stair whose landing is not in the map is a floor the player can enter and not leave, and a building whose entrance is not a transition is a door drawn on a wall. Both ends have to be in the same file. |
assembly_leaked | Teardown released fewer nodes than the build took. | Nothing to fix in your package, and nothing in one can cause this. Teardown released fewer nodes than the build took, so something is holding part of the previous map: write it up with the map that produced it. It shows as memory growing each time the same place is entered. |
assembly_missing_aspect | A required part of the layout produced nothing, so the map cannot be stood in. | Add the missing part on its own layer and export again. The message names which one: walkable rectangles go on the walkable layer, colliders on collision, and the ground tile layer has to cover the map. A map with nothing walkable is a place the player can enter and not move in, so it is refused rather than shipped. |
assembly_over_budget | The assembled shape is past a declared ceiling. | Remove some of what the message counted, or split the map. The ceiling is measured on what was assembled rather than on the file size, so compressing the layers changes nothing: the number in the message is what the machine would have had to hold. |
assembly_refused | Nothing was built, because building half a map is worse than building none. | Fix every other message in this run and export again. Nothing was built, deliberately: half a map is a room you walk into with no walls, and there would be no way to tell which half arrived. |
assembly_unreachable_affordance | An anchor sits where nothing is declared walkable, so nobody can reach what it offers. | Draw a walkable rectangle touching the anchor the message names, on the same floor. An anchor is the thing itself and the actor stands beside it, so a counter fully inside a walkable area is one the player walks through and a counter with nothing around it is a service nobody can reach. Check the floor property first: an anchor upstairs is not served by the room below it. |
asset_invalid | A descriptor exists and this build refuses it. The verdict says which rule. | Read the verdict beside the code. It names the single rule the record broke, and every one of them is a field to remove, a field to add or a word to change. |
asset_kind_mismatch | The caller asked for one kind and the descriptor declares another. Resolving it anyway would hand a texture to something expecting a sound. | Bind an asset of the kind the slot takes. Nothing draws a portrait for a region, so the binding would never render and nothing would say why. |
asset_missing | The package is installed and declares no descriptor under that id. | The asset id resolves to nothing in the composition. Ship the file, or point the binding at one your package actually contains. |
asset_package_missing | No installed package answers to the namespace the id names. | The package that supplied this asset is not installed. Reinstall it, or rebind the slot to something in a package that is. |
audio_cue_refused | A cue or playlist this build will not register. | Read the rule named beside the code and re-encode to match it. The usual causes are a format we do not decode and a length past the declared ceiling. |
audio_placeholder_used | A refused or missing sound resolved to the reserved stand-in, and the caller was told. | Nothing to fix if you meant it. The cue was missing, so the stand-in for its kind played and the substitution was reported rather than left silent. |
audio_refused | A sound this build will not play. The verdict says which rule. | The audio file itself was refused before anything played it. Re-encode to a declared format inside the declared size, and keep the sample rate on the published list. |
audio_stream_overran | A stream that ran past the length its descriptor declared, stopped at the bound. | The stream is longer than its slot allows. Trim it, or move it to a slot whose budget covers it: a stream that overruns is one the mixer has to cut mid-word. |
binding_invalid | A binding this build will not make. | Read the verdict beside the code. It names the single rule the record broke, and every one of them is a field to remove, a field to add or a word to change. |
binding_missing_alternative | Information a player cannot receive, with nothing else carrying it. | A portrait and an icon need a description key, and a voice line needs a subtitle key. Add the alternative: this is the one accessibility rule a player feels directly. |
binding_over_budget | A package's presentation past what one of the totals allows. | The report prints what was measured beside what was allowed. Split the record, or drop the entries past the line: the ceiling is what a screen or a reader can hold, not a suggestion. |
binding_placeholder_used | A cue nothing supplies, resolved to the stand-in for its kind. | Nothing to fix if you meant it. The binding resolved to the stand-in for its slot's asset kind, and the report says which slot so you can tell a decision from an omission. |
boot_attempt_recorded | An attempt was written before composition began. | Nothing to fix. The boot was written down before it was tried, which is what lets the next start know something went wrong last time. |
boot_last_known_good_offered | A profile that booted before is offered instead. | The composition that failed is not the one that worked, so the last one that did was offered. Take it, then reinstall the packages one at a time to find the one that broke. |
boot_marker_unwritable | The attempt could not be recorded. The game boots with recovery disabled for the session and says so. | The boot marker could not be written, so a crash cannot be told from a clean exit next time. Check the game's own data folder is writable; this is not about your package. |
boot_package_rejected | A package was refused during recovery. The id is recorded, never the path. | This package stopped the game from starting and was left out. The report names the refusal; fix it and reinstall, or leave it out. |
boot_retry_offered | Under the bound; the same profile is tried again. | Take the retry. If the second start fails too, the composition rather than the moment is the problem, and the next offer will be safe mode. |
boot_safe_mode_offered | The bound is spent; the game offers to start with no packages. | Take safe mode, then remove packages one at a time until it starts. Your save is untouched: safe mode reads it and never writes over it. |
budget_aggregate_exceeded | The installed set crossed the shared limit. The package named is the one that crossed it, in load order. | Drop something from the composition. Every package together is past the ceiling, not yours alone, and the report prints the total beside the limit. |
budget_degraded | A dimension was reduced instead of refused, and what it fell back to is named. | Nothing to fix unless you meant otherwise. The composition stayed inside its budget by dropping quality rather than content: nothing is missing, something is smaller. |
budget_package_exceeded | One package crossed its own limit on a dimension. | Read the dimension named in the report and cut what it measures. It prints what your package used beside what one package is allowed. |
budget_soft_warning | Over the soft line, loaded anyway, reported to the creator. | Nothing to fix yet. You are close enough to a ceiling that one more package would cross it, which is worth knowing before somebody else's does. |
budget_unknown_dimension | Usage was reported for a dimension this build does not declare, which is a creator writing against a surface that does not exist. | The budget names something this build does not measure. Pick a dimension from the published list: one we do not measure would never be enforced. |
budget_unknown_profile | A profile this build does not declare. The strictest profile is applied rather than the widest. | Pick a budget profile from the published list. Profiles are chosen rather than described, so one we do not publish is one nothing would enforce. |
campaign_hardcoded_identity | A consumer spelled the vanilla campaign id out instead of asking which campaign is selected. | Something reached for the base campaign's id, text or scene by name instead of asking the selected campaign. Resolve it through the campaign context, or a conversion gets this world's answer in that world's place. |
campaign_invalid | The declaration is not one this build would select. | Read the verdict beside the code. It names the single rule the record broke, and every one of them is a field to remove, a field to add or a word to change. |
campaign_not_found | A campaign was selected by id and no declaration answers to it. | The campaign id named is not installed. Check the spelling, or install the package that provides it: nothing falls back to the base campaign here. |
character_carries_handler | Public data naming a private symbol. | Remove the field naming a method or a callback. Publishing an internal symbol makes it a public contract, and the rename that breaks it is reported by players as the game breaking. |
character_invalid | The record is not one this build would load. | Read the verdict beside the code. It names the single rule the record broke, and every one of them is a field to remove, a field to add or a word to change. |
character_origin_missing | A character whose origin nothing supplies, loaded with the fallback. | Reinstall the package that wrote this origin. The character loaded with the reserved fallback and the id they lost travels with them, so reinstalling puts them back exactly. |
character_tree_unenterable | A cycle or an unreachable node in a progression tree. | Read whether the report says cycle or orphan and reconnect that part of the tree. Neither is an error in play: the option is simply never offered and the file looks correct. |
character_unknown_property | A property that would mean nothing to any system. | Use a property this build has. Accepting one it does not would put a field in the save that means nothing to any system and explains itself to nobody, forever. |
combat_carries_formula | A package trying to supply the arithmetic of a fight. | Remove the field. Tier 1 is data: what runs belongs to the game, and a trusted provider is where new behaviour goes. |
combat_effect_expired | A saved status whose tuning is gone, ended at load. | The package that tuned this status is no longer installed, so the status ended rather than being applied with numbers nobody has. Reinstall it to keep it. |
combat_invalid | The record is not one this build would load. | Read the verdict beside the code. It names the single rule the record broke, and every one of them is a field to remove, a field to add or a word to change. |
combat_over_budget | A package past a declared ceiling. | The report prints what was measured beside what was allowed. Split the record, or drop the entries past the line: the ceiling is what a screen or a reader can hold, not a suggestion. |
combat_phase_unreachable | A phase written and never reached. | Two phases share a threshold, so one of them would never run. Give them different remaining-health values. |
combat_unknown_operation | An operation or a parameter the resolver does not have. | Pick the operation from the published list and give it only its own parameters. A knockback carrying an amount is a row misread, not a stronger knockback. |
combat_unknown_status | A status this build does not publish. | Tune a status this build publishes rather than inventing one. An invented status sits on a unit behind a blank icon with nothing to remove it. |
combat_unresolved_reference | A named record nothing supplies. | The id named is one nothing in the composition supplies. Either ship the record it points at, or depend on the package that does. |
conversion_external_reference | An address or a path reaching outside the package. | A conversion pointed outside its own package. Ship what you reference, or the world reaches into one you did not write. |
conversion_fallback_allowed | A conversion that has not denied vanilla fallback, in the manifest or in the campaign. | Nothing to fix. This family may fall through to the base game, and the row says why that is not a leak. |
conversion_family_unclassified | A content family exists and this catalogue does not classify it. | Declare the family as campaign-owned or engine-owned. An unclassified family is one the vanilla-leak audit cannot reason about at all. |
conversion_missing_file | A file the fixture declares and does not have. | A required file the conversion declared is not in the package. Ship it: a conversion missing part of itself starts and then cannot continue. |
conversion_partial_atomic_family | An atomic family was supplied in part. Refused rather than shipped with the seam. | Ship the whole family or none of it. Half a quest list is a world referring to quests nobody wrote. |
conversion_required_family_missing | A campaign-owned family the conversion had to supply is absent. | Ship the family. The catalogue says which are required of a conversion, and without them the world is the base game with new art. |
conversion_unclaimed_family | A family the package supplies files for and the campaign does not claim. | Claim the family in a package, or declare it deferred with a reason. Left unclaimed, nothing says whether its absence was meant. |
conversion_unknown_family | The campaign declared a family this build does not know. | Name a family from the content type catalogue. Families are declared there before anything can supply one. |
conversion_vanilla_leak | An id belonging to neither the conversion nor the reserved engine namespace. | Replace the id, the text or the scene the report names. This conversion denies fallback, so the alternative is the player seeing this world inside that one. |
dialogue_carries_expression | A package trying to run something, or to write the words in place. | Remove the field. Tier 1 is data: what runs belongs to the game, and a trusted provider is where new behaviour goes. |
dialogue_dead_end | A conversation a player cannot leave. | Point an option from this node at a path that reaches an ending. A cycle is fine; a cycle with no way out is a conversation the player cannot leave. |
dialogue_invalid | The record is not one this build would load. | Read the verdict beside the code. It names the single rule the record broke, and every one of them is a field to remove, a field to add or a word to change. |
dialogue_orphan_node | A node nothing can arrive at. | Nothing goes to this node, so no player will read it. Point an option at it or delete it. |
dialogue_over_budget | A package past a declared ceiling. | The report prints what was measured beside what was allowed. Split the record, or drop the entries past the line: the ceiling is what a screen or a reader can hold, not a suggestion. |
dialogue_text_not_key | A sentence where a catalogue key belongs. | Move the sentence into your locale catalogue and put its key in the field. A field ending _key holds a key; a sentence there ships your package in one language forever. |
dialogue_unguarded_reveal | A secret with nothing in front of it. | Put at least one condition on the node. An empty condition list counts as none: the secret would go to everybody who opens the conversation. |
dialogue_unknown_step | A check or a command this build does not have. | The name is not on the published list for that table. Pick one that is: the list is short because every entry is something this build already does. |
dialogue_unresolved_reference | A named record nothing supplies. | The id named is one nothing in the composition supplies. Either ship the record it points at, or depend on the package that does. |
distribution_hash_mismatch | The files on disk are not the ones the record describes. | The package on disk is not the one that was published. Download it again: a mismatch is either a broken transfer or a file somebody else changed. |
distribution_invalid | The record is not one this build would accept. | Read the verdict beside the code. It names the single rule the record broke, and every one of them is a field to remove, a field to add or a word to change. |
distribution_offline | Provenance was checked against what was recorded at install time. | Check the connection, then try again. Installed packages keep working; new ones cannot be verified until the service is back. |
distribution_reported | Somebody has reported it. The player is told and decides. | Read the report and decide. The package still runs: the report exists so the decision is made with it in front of you rather than instead of it. |
distribution_revoked | The package is quarantined or withdrawn. It does not install. | Remove the package. It was withdrawn after publication and is not loaded; if you wrote it, the revocation notice says why. |
distribution_update_deferred | A new version is available and a campaign is running on the old one. | Restart to pick the update up. Swapping a package under a running save is how a save starts naming things nobody supplies. |
economy_expression_refused | A field that would carry something to evaluate. | A pricing input tried to carry something evaluable. Choose a curve by name from the four this build implements: a price that can execute is the Tier 1 boundary reached through the economy. |
economy_invalid | The record is not one this build would load. | Read the verdict beside the code. It names the single rule the record broke, and every one of them is a field to remove, a field to add or a word to change. |
economy_out_of_bounds | A number outside its declared range. | Move the number inside its declared range. Every numeric field here has one, and a field with no declared range is refused rather than waved through. |
economy_overridden | A package replaced a record another package or the base game declared. | Nothing to fix. Another package took this record, and the report names which one so you can decide whether that was the intention. |
economy_unresolved_reference | A recipe or pool naming content nothing supplies. | The id named is one nothing in the composition supplies. Either ship the record it points at, or depend on the package that does. |
effect_carries_source | A descriptor that named a shader, a scene or code. | Remove the field naming a shader, a script or a scene. Effects are composed from the engine's primitives; supplying the source is supplying a program. |
effect_degraded | An effect reduced to fit the frame, named so the reduction is not a mystery. | Nothing to fix unless you meant otherwise. The effect ran at lower quality to stay inside the frame budget: it is still there, and smaller. |
effect_dropped | An effect that could not fit at all. | Read which dimension the budget report says filled up, then cut it. The frame ran out of room and this effect was not drawn at all. |
effect_refused | An effect this build will not compose. The verdict says which rule. | Read the parameter named in the report and put it inside the range printed beside it. The effect was refused before anything drew it. |
extension_forbidden_field | The manifest carries a field no extension may carry, such as a script path, a resolver or a private import. | Remove the field. A script path, a resolver, a private import or a project-settings edit reaches past the contract into the host, and an extension that needs one of them is asking for a capability rather than declaring a feature: declare the capability under requires instead. |
extension_foreign_namespace | The declared state namespace is not the extension's own package id, so its state would be unattributable once it is gone. | Rename state_namespace to the extension's own package id. State written anywhere else cannot be attributed or removed once the package is gone, and the host reserves its own names. |
extension_missing_field | A field the host needs in order to install, fingerprint, roll back or remove the extension is absent. | Add the field to the extension manifest. Every one of them is something the host needs before it can order the extension, put it in the composition fingerprint, roll it back or take it out again, so a manifest missing one describes a feature the host could install and never remove. |
extension_teardown_incomplete | Removing the extension gave back less than it took, which leaks on the next install rather than now. | Give back everything the extension took before it reports teardown complete, and update takes if the real number changed. Under-releasing does not fail now; it leaks on the next install, and installing the same extension twice in one session is ordinary. |
extension_unbounded | A declared budget is missing or is not a number, so nothing can ever be over it. | Write a number for every budget the contract names. A budget left out, or written as a phrase, reads as declared and compares against nothing, so the extension can never be over it. |
extension_unknown_capability | The extension requires or provides a capability this build does not publish. | Check the capability ids against the ones this build publishes, and remove or rename the one that does not match. Refusing before install is deliberate: an extension that discovered a missing capability while running would discover it in front of a player. |
extension_would_add_autoload | The manifest would add a global. Refused whatever the trust tier says, because a package that could add one would undo the retirement this build is built on. | Remove the autoload, the singleton or the core-autoload name from the manifest. No trust tier grants this one: the whole migration this build rests on is the retirement of globals, so an extension that could add one would undo it from outside. Put the state under the extension's own namespace and reach the host through the capabilities it publishes. |
font_locale_fallback | A glyph the chosen font does not carry, drawn by the fallback and reported. | The font you supplied has no glyphs for this language, so the fallback drew it. Ship a face that covers the locale, or the text is boxes. |
font_refused | A font this build will not use. The verdict says which rule. | The font file was refused. Check the format is one we load and that the file carries no embedded program. |
image_bomb_refused | A header claiming more than the decode budget, refused before any decoder saw it. | The image decompresses to far more than its file size suggests. Re-export it at its real dimensions: this is refused before it is decoded, because decoding it is the damage. |
image_frame_grid | A sheet whose declared grid does not divide it exactly. | The sprite sheet's frames do not divide its dimensions evenly. Fix the grid, or every frame after the first is drawn off by the remainder. |
image_hash_mismatch | Bytes that changed after the descriptor was validated. | The image is not the one that was declared. Re-export or re-declare: a mismatch means the file and the manifest disagree about what shipped. |
image_placeholder_used | A refused or missing image resolved to the reserved stand-in, and the caller was told. | Nothing to fix if you meant it. The image was missing and the stand-in for its kind was drawn, with the substitution reported rather than left blank. |
image_refused | An image this build will not decode. The verdict says which rule. | Re-export inside the dimension, format and budget the report names. The image was refused before anything drew it. |
inference_cancelled | A run passed its latency budget and was stopped. The heuristic answered, so the caller was not left waiting. | Trim the model, or raise max_latency_usec if the budget was genuinely too tight. The run passed its budget and was stopped, and the heuristic answered rather than leaving the caller waiting: a frame spent waiting is a frame the player watched. |
inference_digest_mismatch | The model bytes are not the ones recorded, which makes it a different model rather than an older one. | Update the declared digest to the SHA-256 of the model you are shipping, or ship the model the digest names. The bytes differing means this is a different model, and there is no other way to see the difference. |
inference_forbidden_field | The declaration carries a runtime, a script, an endpoint, telemetry or a claim to authority. | Remove the field. The engine owns the adapter, so a package supplies weights and never the code that runs them; an endpoint would put the model somewhere it can be changed after review, and telemetry would report what a player asked. Ship the weights and let the engine run them. |
inference_load_refused | The declaration is sound and this build ships no inference adapter to load it into, so the deterministic heuristic answered. | Nothing to fix in your package. This build ships no inference adapter, so no model of any format is loaded and the deterministic heuristic answers. The declaration is validated anyway, so a model shipped today is one an adapter could load unchanged. |
inference_missing_field | A model declaration is missing something the adapter needs before it spends anything on a run. | Add the field to the model declaration. Each one is something the adapter needs before it spends anything on a run: the digest is the model's identity, the schemas are what it is fed and what will be accepted back, and the platforms are where it is allowed to be tried at all. |
inference_output_rejected | The model's answer did not match its declared output schema and was discarded rather than parsed leniently. | Check what the model returns against the declared output schema. An answer that is almost right is discarded rather than parsed leniently, because a proposal that slips through in the wrong shape is one every later check reads as valid. |
inference_unbounded | A declared limit is missing or is not a number, so nothing can ever be over it. | Write a number for every limit the contract names. A limit left out, or written as a word, reads as declared and compares against nothing, so the run that goes wrong is the one nothing was measuring. |
inference_unknown_format | The model's format is not one the engine's own adapter implements. | Re-export the model in one of the formats the engine's adapter implements. A format it does not implement is not a slower path, it is nothing at all. |
inference_wrong_platform | The model does not declare this platform, so the deterministic heuristic answered instead. | Nothing to fix if this is deliberate. The model does not declare this platform, so the deterministic heuristic answered here; add the platform to the declaration only once the model has actually been built and tried on it. |
input_binding_conflict | The suggested key was already bound. The existing binding kept it and the suggestion was recorded rather than applied. | Nothing to fix in your package, and nothing was taken. The key was already bound, so the player's binding kept it and your action is offered unbound in the binding screen. Pick a less crowded default if you would rather it landed on install. |
input_binding_orphaned | A remap belongs to an action no installed package declares. Kept rather than dropped, so reinstalling finds it where the player left it. | Nothing to fix. The remap belongs to an action no installed package declares and it is kept rather than dropped, so reinstalling the package finds the key where the player left it. |
input_core_action | A package named or tried to replace one of the host's own actions. | Rename the action. The game's own actions belong to the host and a package may neither declare one nor replace it; taking pause makes a game somebody cannot leave. Declare your own action beside it and let the player bind both. |
input_forbidden_field | The declaration carries a raw device read, a listener, a focus grab or a binding the player could not change. | Remove the field. Declaring an action is the whole interface: reading a device directly, installing a listener, grabbing focus or forcing a binding all take a decision away from the player and are invisible in every screen they could check. |
input_foreign_namespace | The action id is not namespaced to the package declaring it. | Rename the action id to your own package's namespace. The player's remap is attached to it, so an id belonging to somebody else is a remap that changes owner when either package is removed. |
input_keyboard_unreachable | The action has no keyboard binding, so a player without a controller cannot reach it. | Write a keyboard binding beside the controller one. A pad binding on its own is unreachable for a player without a pad, on a game that is otherwise playable without one, and it fails for them silently. |
input_missing_field | An action declaration is missing something the binding screen or the remap needs. | Add the field to the action declaration. Each one is something a screen needs: the label key is what the binding list shows instead of your internal id, and the context is what tells the game when the action is live rather than firing while somebody is typing a save name. |
input_reserved_chord | A package asked for a chord belonging to the system or the window. | Pick another key. Closing the window, leaving the game and the platform key belong to the operating system, and a package holding one could stop somebody quitting. |
input_unknown_context | The declared context is not one this build publishes, so nothing knows when the action is live. | Choose one of the published contexts. Nothing knows when an action in an unpublished context is live, so it would either never fire or always fire, and neither is what was meant. |
lore_invalid | The record is not one this build would load. | Read the verdict beside the code. It names the single rule the record broke, and every one of them is a field to remove, a field to add or a word to change. |
lore_player_state_in_definition | A field describing a player found inside a definition. | Remove the field. A lore record is a definition and what the player knows is state: loading lore never grants knowledge, so a field claiming it does would be one nothing reads. |
lore_replaced | A package replaced an existing entry, keeping its id and its links. | Nothing to fix. Another package replaced this entry and kept its id, so everything pointing at it still points at something. |
lore_unresolved_reference | A relates_to, reveals or requires id nothing supplies. | The id named is one nothing in the composition supplies. Either ship the record it points at, or depend on the package that does. |
lore_verbatim_text | Text shipped in place of a catalogue key, so it would appear in one language. | A field ending _key holds a sentence. Move it into your locale catalogue: it passes every other check here and ships your lore in one language. |
map_bad_layer_data | A layer that does not cover the map it belongs to. | Re-export the layer uncompressed, with no chunks and no short rows. The subset we accept is the subset we can check. |
map_carries_path | A map naming something to load or execute. | Remove the field pointing at a file. A map names its tiles from the kit; a path is how a package reaches outside itself. |
map_duplicate_anchor | Two objects claiming one id. | Two anchors share a name, so which one a thing attaches to depends on read order. Rename one. |
map_invalid | The map is not one this build would assemble. | Read the verdict beside the code. It names the single rule the record broke, and every one of them is a field to remove, a field to add or a word to change. |
map_kit_mismatch | A map drawn against a kit this build does not have. | The map was authored against a different kit than this build publishes. Re-export against the current kit: the roles and classes moved. |
map_layer_missing | A required layer is absent or drawn wrong. | Add the layer the contract requires. The kit's project file lists them, and a missing one is a map that loads with a hole in it. |
map_normalization_failed | The map produced no canonical form. | Remove the construct the report names, then export again. Without a canonical form the runtime and the offline validator cannot be shown to agree about this map at all. |
map_outside_package | A tileset or template from outside the package. | The map points at something outside its own package. Ship it inside, or the map depends on whatever happens to be installed. |
map_over_budget | A normalised map past a declared limit. | The report prints what was measured beside what was allowed. Split the record, or drop the entries past the line: the ceiling is what a screen or a reader can hold, not a suggestion. |
map_unresolved_reference | A named id nothing supplies. | The id named is one nothing in the composition supplies. Either ship the record it points at, or depend on the package that does. |
map_unsupported_subset | Something in the file is outside the format subset. | The file uses a Tiled feature outside the subset this build reads: compressed layers, image layers, groups, chunks or polygons. Re-export without it. |
pack_claims_host_surface | An autoload, input action or project setting. | Override the content instead of the file. Packs add and replace content; the surfaces the game itself needs to start are not among them. |
pack_collision | A claim another pack already holds. | Give the file up in one of the two packs. The report names both, and leaving it would make the winner mount order. |
pack_mount_failed | The engine would not mount it; the previous set stands. | The pack could not be mounted at all. Check the archive is intact and in a format this build reads. |
pack_replaces_vanilla | A claim the base game holds. | Override the content rather than the file, or request the trusted replacement capability. Replacing a base-game file is not something a data package does. |
pack_restart_required | The mounted set would change, and the change cannot take effect in this process. | The change cannot be applied to a running game. Restart: swapping mounted content mid-run leaves half the game reading the old files. |
progress_invalid | The record is not one this build would load. | Read the verdict beside the code. It names the single rule the record broke, and every one of them is a field to remove, a field to add or a word to change. |
progress_origin_lost | An earned milestone whose package is gone, kept and named. | Nothing to fix. The package that declared this is gone and what the player earned stayed; the id is there so they know what would bring it back. |
progress_over_budget | A package past a declared ceiling. | The report prints what was measured beside what was allowed. Split the record, or drop the entries past the line: the ceiling is what a screen or a reader can hold, not a suggestion. |
progress_reaches_outside | A package trying to run something, or to speak for the platform. | Remove the field. Whether a milestone reaches the platform is worked out from its namespace and is never something a record says. |
progress_reserved_namespace | A package claiming a namespace that is not its own. | Use your own package namespace. The reserved ones are ours, and a milestone that looked like a platform one would be believed. |
progress_text_not_key | A sentence where a catalogue key belongs. | Move the sentence into your locale catalogue and put its key in the field. A field ending _key holds a key; a sentence there ships your package in one language forever. |
progress_unknown_step | A condition or a modifier this build does not have. | The name is not on the published list for that table. Pick one that is: the list is short because every entry is something this build already does. |
progress_unresolved_reference | A named record nothing supplies. | The id named is one nothing in the composition supplies. Either ship the record it points at, or depend on the package that does. |
provider_forbidden_capability | A package asked for a capability no tier grants, such as the filesystem, the network or a process. | Remove the capability. No tier grants the filesystem, the network, a process or another package's namespace, and no review or signature changes that: what a build does with the filesystem is not a property anybody can check by reading it. Use propose_intent to hand the canonical owner a proposal instead, and write_own_namespace for state that has to survive. |
provider_native_extension_refused | A native extension was found. It holds full process authority before any policy runs, so it is refused under its own reason rather than as a failed trust check. | Remove the extension from the package. The engine loads a GDExtension with full process authority before any of this policy runs, so it is refused for what it is rather than for who signed it, and ratifying the provider tier will not change that answer. |
provider_tier_inactive | The behaviour-provider tier is not ratified in this build, so nothing that would run was loaded. | Nothing to fix in your package. This build has not ratified the behaviour-provider tier, so a script, a packed scene or a native extension is refused whoever signed it. Ship the mechanic as declarative records if one of the published families can carry it, and read the release notes for the build that ratifies the tier before writing a provider against it. |
provider_unknown_capability | A package asked for a capability this build does not publish. | Remove the capability from the manifest, or replace it with one this build publishes. The list is short on purpose: a permission nothing publishes is one the host cannot reason about, so it is refused rather than ignored. |
provider_unknown_load_kind | A package offered something this build does not classify. Refused rather than treated as data, because guessing wrong here guesses in the direction of running it. | Check what the package offered against the four kinds this build classifies: declarative data, a packed scene, a script or a native extension. Anything else is refused rather than guessed at, because guessing here guesses in the direction of running it. |
provider_untrusted | The package is in a trust state that loads nothing, which is the state every package starts in. | Nothing to fix in your package, and nothing a package can fix on its own. Trust is attached to the exact bytes and is given by the player, so install the package from a source they chose and let them confirm it; a revoked digest cannot be re-trusted by being detected again. |
provider_user_trust_required | The tier would allow it and no person has said yes to this exact content yet. | Take the player to the mod list and let them confirm this package. The tier would allow it and nobody has said yes to this exact content yet, which is the one step a signature deliberately does not replace. |
quest_already_applied | A command that had already been carried out. | Nothing to fix. The command had already been carried out and was not repeated, which is what keeps a reload from paying a reward twice. |
quest_carries_expression | A package trying to run something, or to keep the once-only answer itself. | Remove the field. Tier 1 is data: what runs belongs to the game, and a trusted provider is where new behaviour goes. |
quest_ending_unreachable | An ending nobody can arrive at. | The ending requires a quest its arc does not contain. Add the quest to the arc's stages, or require one that is in them. |
quest_invalid | The record is not one this build would load. | Read the verdict beside the code. It names the single rule the record broke, and every one of them is a field to remove, a field to add or a word to change. |
quest_nothing_applied | A plan that refused something, so nothing ran. | Something in the set was refused, so none of it was applied. Fix every refusal in the report: a half-applied arc is a state nothing can describe. |
quest_over_budget | A package past a declared ceiling. | The report prints what was measured beside what was allowed. Split the record, or drop the entries past the line: the ceiling is what a screen or a reader can hold, not a suggestion. |
quest_text_not_key | A sentence where a catalogue key belongs. | Move the sentence into your locale catalogue and put its key in the field. A field ending _key holds a key; a sentence there ships your package in one language forever. |
quest_unfinishable | A quest nobody can complete. | Give the quest at least one objective that is not optional, or it completes the moment it is accepted. |
quest_unknown_step | A goal or a grant this build does not have. | The name is not on the published list for that table. Pick one that is: the list is short because every entry is something this build already does. |
quest_unresolved_reference | A named record nothing supplies. | The id named is one nothing in the composition supplies. Either ship the record it points at, or depend on the package that does. |
relationship_band_gap | A range with a value no band names. | Cover your policy's whole floor-to-ceiling range with no gap and no overlap, using only the published band names. A value in a gap is one no screen can name. |
relationship_carries_behaviour_code | A package trying to supply what runs when a relationship changes. | Remove the field. Tier 1 is data: what runs belongs to the game, and a trusted provider is where new behaviour goes. |
relationship_declares_live_state | A package trying to say where a relationship is now. | Declare the starting value instead. Where a relationship stands now is the runtime's, and the player is the one who moved it. |
relationship_invalid | The record is not one this build would load. | Read the verdict beside the code. It names the single rule the record broke, and every one of them is a field to remove, a field to add or a word to change. |
relationship_leave_unreachable | A companion who can never leave, or who leaves at once. | Move the leaving threshold inside the policy's own range. At the floor the companion can never be lost; above the ceiling they leave on the first evening. |
relationship_origin_lost | The package that declared a bond is gone and the evolved value stayed. | Nothing to fix. The package that declared this is gone and what the player earned stayed; the id is there so they know what would bring it back. |
relationship_pair_collision | An ordered pair declared twice, left with no bond. | Two records claim the same ordered pair. Delete one: keeping either would make the winner whichever package loaded first. |
relationship_unknown_relation | A relation kind or recruitment route this build does not have. | Pick a relation from the published list. One this build does not have would be flattened to nothing, and the story you wrote would not happen. |
relationship_unresolved_reference | A named record nothing supplies. | The id named is one nothing in the composition supplies. Either ship the record it points at, or depend on the package that does. |
resolution_cache_invalidated | Entries dropped because their provider is no longer active. | Nothing to fix. The composition changed, so what ids resolve to was worked out again rather than reused. |
resolution_missing | Nothing supplies the id and nothing may fall back. | Reinstall the package that supplied the id, or correct the id. The report keeps it so you can tell which of the two happened. |
resolution_over_budget | A package or the process past a declared budget. | The report prints what was measured beside what was allowed. Split the record, or drop the entries past the line: the ceiling is what a screen or a reader can hold, not a suggestion. |
resolution_refused | A request this build will not make. | Read the rule the report names and change the id to something that place accepts. |
resolution_restart_required | A profile change whose old bytes cannot be unloaded. | What this id resolves to changed in a way a running game cannot follow. Restart to pick it up. |
resolution_tombstoned | A known id that a package removed. | Reinstall the package to turn the tombstone back into real content. The id resolves to its tombstone rather than to nothing, which is exactly why reinstalling gives the player their things back. |
save_campaign_absent | The campaign the save was made under is not installed. | Install the campaign the save names. The save is not opened against a different one, because that would silently be another world. |
save_composition_matches | Nothing the save recorded has moved. | Nothing to fix. The packages this save was made with are the packages that are installed. |
save_content_absent | Content the save names is not installed. Reported, not fatal. | Reinstall the packages the report lists. The ids stay in the save, so what is currently unanswerable becomes answerable again. |
save_orphan_state_carried | State with no installed owner was carried forward without being executed. | Reinstall the package to make the state mean something again. It was kept rather than dropped precisely so that works. |
save_provider_contract_moved | A capability the save relied on now answers to a different contract. | Read whether the report says migrated, ignored or rejected. A provider's contract version changed between the save and now, and those are the three answers. |
save_write_beside_original | The session wrote a new file and left the original alone. | Nothing to fix. The recovered save was written beside the original rather than over it: safe mode never overwrites the file it could not open. |
selection_campaign_missing | The campaign asked for is not installed and nothing was composed in its place. | Install the package that supplies the campaign id in the message, or start a different campaign. The id is the one that was asked for, spelled the way it was asked for, so compare it with the id in the package's campaign.json. Nothing was loaded in its place: starting the base game instead would hand you a different world under the name you typed. |
selection_made | A campaign was selected, with the reason it was. | Nothing to fix. This is the line that says which world this run is and why it was chosen, and it is worth reading when the world is not the one you expected: the reason tells you whether it was requested, resumed or the only one installed. |
selection_safe_mode | Packages were left out, either after a failed start or because the player asked. | Read the reason before changing anything. player_asked means you chose this and nothing is wrong. boot_failed means starting the campaign failed repeatedly, so disable the mods you installed most recently, start once, and add them back one at a time. No packages are composed in safe mode, so a run that works here says the fault is in a package. |
selection_save_preserved | A recovered save was written beside the original rather than over it. | Nothing to fix, and do not delete the second file. Recovery wrote its result beside your save rather than over it, so the message names both paths: the original is the one without the .recovered suffix, and it is still exactly as it was. |
setting_invalid | The declaration is not one this build would draw. | Read the verdict beside the code. It names the single rule the record broke, and every one of them is a field to remove, a field to add or a word to change. |
setting_migrated | A value arrived under an old key and was carried to the current one. | Nothing to fix. The setting moved to its current shape and the old value came across. |
setting_owner_missing | A stored value belongs to a package that is not installed. Kept, not applied. | Name a declared owner for the setting. One this build does not have is a setting nothing would ever read. |
setting_restart_required | The value changed and cannot take effect until the next launch. | Restart to apply it. The setting cannot change while the game is running. |
setting_secret_withheld | A secret was excluded from a save or a report. | Nothing to fix. A value marked secret was left out of the report, which is why the report can be pasted into a bug thread. |
setting_value_refused | A value outside what the declaration allows. The default answers instead. | Move the value inside the range printed beside it, or pick one from the published list. The report shows both. |
settlement_building_unresolved | A saved building whose package is gone, named rather than replaced. | Reinstall the package the message names to get the building back, or leave it: nothing was lost. The building is still in the save under its own id and was deliberately not replaced with the nearest one this build has, which would have put somebody else's goods in a smithy you never built. |
settlement_carries_path | A record naming a scene, a script or a position of its own. | Remove the field and name an id instead. An interior is an interior record's id, never a scene path, and a building is placed by naming an object on the map rather than by carrying its own coordinates. The position fields are refused for that second reason: a record that holds a coordinate is one that can be dropped somewhere when its map is gone. |
settlement_invalid | A settlement record this build will not load. | Read the verdict beside the code and correct the record it names. The usual causes are a required field left out, an id without your package namespace in front of it, and a field that belongs to one of the other five record kinds. |
settlement_loot_table_absent | A container whose loot table left with its package, so it is empty rather than rolled from another. | Reinstall the package that supplied the loot table if you want the container to fill again. It is empty rather than rolled from another table, because rolling would hand out another world's things under this world's name. |
settlement_over_budget | More buildings or services than a settlement can hold. | Remove some of what the message counted, or split the settlement in two. The count and the ceiling are both in the message, so the difference is how many have to go. |
settlement_text_not_key | A displayed field holding a sentence rather than a catalogue key. | Replace the sentence with a catalogue key and put the sentence in your locale table. A name written into the record cannot be translated and shows in English in every language. |
settlement_unreachable_service | A service offered at an anchor the map does not declare. | Add the anchor to the map's anchors layer with the id the service names, or rename the service to one that is there. The record and the map are each legal on their own, and the result is a counter with nobody able to stand at it. |
settlement_unresolved_reference | A record naming something nothing supplies. | Add the record being named to your package, or correct the id. The message names both the record that pointed and the id it pointed at, so compare it against the ids you declared. |
society_dimension_leak | An identity dimension found carrying another one. | Remove the field naming the other dimension. A culture has no faith field: the derivation is meant to be unwritable rather than discouraged, so nothing can hand a faction a faith nobody wrote. |
society_heraldry_path | A device naming a file instead of a cue. | A heraldic device named a file. Name a cue instead: a device is chosen from what this build draws. |
society_invalid | The record is not one this build would load. | Read the verdict beside the code. It names the single rule the record broke, and every one of them is a field to remove, a field to add or a word to change. |
society_mutation_in_definition | A definition asking to run something. | Remove the field. Definition adapters describe; they do not hold the authority to change what they describe. |
society_relationship_collision | The same ordered pair declared twice. | An ordered pair was declared twice, so the pair has no stance at all. Delete one: keeping either would make the world depend on which package loaded last. |
society_relationship_one_sided | A stance whose reverse nobody declared; the reverse is the default. | Nothing to fix if you meant it. Only one direction of this pair is declared and the undeclared reverse is the default: a faction that does not know it has an enemy is a premise, and this line is here so it is a chosen one. |
society_unresolved_reference | A named record nothing supplies. | The id named is one nothing in the composition supplies. Either ship the record it points at, or depend on the package that does. |
state_namespace_ignored | The package no longer reads this state. | Declare the namespace, or remove the state. It arrived under one nothing claims and was left alone rather than applied. |
state_namespace_malformed | A namespace in the save is not a name this build would accept. | Write a namespace that identifies one package unambiguously. Everything in a save hangs off it, so a shape we cannot resolve is state nobody can claim later. |
state_namespace_orphaned | No installed package owns this state and it was carried forward. | Reinstall the package to bring its state back. The namespace belongs to one that is gone and the state was preserved rather than deleted. |
state_namespace_rejected | The package refused its own saved state for this session. | The package tried to write outside the namespace it owns. Write inside your own: another package's state is not yours to change. |
state_namespace_reserved | A package claimed a namespace the host owns. | The namespace is one the host owns. Pick your own package's. |
state_schema_future | State written by a newer version of the package than this build has. | Update the package. The saved state was written by a newer version than the one installed, and downgrading state is not something we guess at. |
state_schema_migrated | State older than the build was handed to the package to bring forward. | Nothing to fix. Older state was brought to the current shape by the package's own migration. |
state_schema_too_old | State older than the floor the package declares it can read. | Read which answer the report says the package chose. The state is older than the oldest shape it migrates from, so the package decides what happens to it. |
state_write_outside_namespace | A writer addressed a namespace that is not its own. | Write inside your own namespace. One package changing another's state is how a save stops belonging to anybody. |
strategic_carries_policy | A package trying to supply the rule itself. | Remove the field. Tier 1 is data: what runs belongs to the game, and a trusted provider is where new behaviour goes. |
strategic_invalid | The record is not one this build would load. | Read the verdict beside the code. It names the single rule the record broke, and every one of them is a field to remove, a field to add or a word to change. |
strategic_over_budget | A package past a declared ceiling. | The report prints what was measured beside what was allowed. Split the record, or drop the entries past the line: the ceiling is what a screen or a reader can hold, not a suggestion. |
strategic_text_not_key | A sentence where a catalogue key belongs. | Move the sentence into your locale catalogue and put its key in the field. A field ending _key holds a key; a sentence there ships your package in one language forever. |
strategic_unknown_term | A consideration, an intent or a class this build does not have. | The word is not on the published list for that field. Pick one that is: nothing would answer under a word we do not have. |
strategic_unresolved_reference | A named record nothing supplies. | The id named is one nothing in the composition supplies. Either ship the record it points at, or depend on the package that does. |
strategic_weightless | A planner that would decide the same way forever. | Give at least one consideration a weight that is not zero. All-zero weights score every option the same and pick the alphabetically first thing forever. |
theme_carries_executable | A theme that named a scene, a script or a resource path. | Remove the field. A theme describes how things look; a script inside one is a program running behind every screen. |
theme_contrast_too_low | A colour pair below the minimum contrast ratio. | Move the foreground or the background until the ratio clears the minimum printed beside it. This is the one theme rule a player feels directly. |
theme_refused | A theme this build will not register. | Read the property the report names and put it inside what it allows. |
tombstone_blocks_campaign | The family declared the campaign cannot load without it. | This family is load-bearing and its package is gone, so the campaign will not start. Reinstall it: starting without it would be a world missing something it refers to everywhere. |
tombstone_placeholder_substituted | An engine-owned stand-in is answering in its place. | Reinstall the package to get the real content back. The report names which stand-in was used in the meantime. |
tombstone_recorded | An id whose package is gone was recorded rather than dropped. | Nothing to fix. A package left and its ids stayed in the save, which is the only reason reinstalling gives the player their things back. |
tombstone_reinstall_recovered | The package came back and the tombstone resolved to real content again. | Nothing to fix. The package came back and its tombstones resolve to real content again. |
tombstone_unresolved_reference | Something in the save still points at a tombstoned id. | The id named is one nothing in the composition supplies. Either ship the record it points at, or depend on the package that does. |
unit_carries_behaviour_code | A package trying to supply behaviour. | Remove the field. Tier 1 is data: what runs belongs to the game, and a trusted provider is where new behaviour goes. |
unit_incomplete_for_category | A unit missing what its category needs. | Add the part the category requires. The categories table lists them: a companion needs a loadout, a troop needs a behaviour, and a boss never falls back to one. |
unit_invalid | The record is not one this build would load. | Read the verdict beside the code. It names the single rule the record broke, and every one of them is a field to remove, a field to add or a word to change. |
unit_unknown_behaviour | A behaviour profile this build does not have. | Pick a behaviour profile from the published list. One this build does not have is refused rather than defaulted, because a boss that quietly became a wanderer is a fight that does not happen. |
unit_unresolved_reference | A named record nothing supplies. | The id named is one nothing in the composition supplies. Either ship the record it points at, or depend on the package that does. |
unit_visual_missing | An appearance resolved to its stand-in. | The appearance you named is not supplied, so the stand-in for that kind was used. Ship the asset or point at one that exists. |
world_calendar_inconsistent | A calendar or holiday that cannot happen. | Fix the pair the report names. Seasons, months and holidays have to agree, and one that does not is a calendar the world reads two ways. |
world_carries_clock_control | A package supplying its own time. | Remove the field. A package declares what a season is worth and does not get to move the clock: the canonical clock has one owner. |
world_carries_randomness | A package supplying its own randomness. | Remove the seed and name a stream instead. The run seed belongs to the host and the stream is derived from it, so a package carrying its own seed is a world that ignores the run it is in. |
world_invalid | The record is not one this build would load. | Read the verdict beside the code. It names the single rule the record broke, and every one of them is a field to remove, a field to add or a word to change. |
world_unresolved_reference | A named record nothing supplies. | The id named is one nothing in the composition supplies. Either ship the record it points at, or depend on the package that does. |
Limits, saves, sharing
What a mod can never do
Refused by the build, not discouraged.
| No code | Scripts, extensions, executables, shell files — anywhere in your package. |
| No scenes or resource files | A record pointing at one is refused even if the file is not there. |
| No shadowing base files | A mod adds and changes records; it cannot replace the game's own files. |
| No new kinds of thing | A new perk yes, a new kind of perk no. |
| No controls, autoloads or project settings | Not reachable at all. |
| No deleting outright | remove withdraws a record from supply and leaves it resolvable. Only vendor_inventories drops one. |
The exact list, because a refusal quotes it:
- One record file may be 4 MB, hold 4 096 records and nest 16 levels deep. The largest catalogue the game ships holds a few hundred, so a file that trips one of these is malformed rather than ambitious.
- Files your package may not contain at all, anywhere in it:
.bat,.cmd,.dll,.dylib,.exe,.gd,.gdc,.gde,.gdextension,.ps1,.sh,.so,.vbs. Every one of them is behaviour: a script, a native library, or something the host would run. - Extensions a record may not point at:
.bat,.cmd,.dll,.dylib,.exe,.gd,.gdc,.gde,.gdextension,.ps1,.res,.scn,.sh,.so,.tres,.tscn,.vbs. That is the list above plus scenes and resources, because a record naming one is asking the game to build it. - Keys refused whatever they hold:
resource_local_to_scene,resource_path,scene,scene_path,script. - Paths refused wherever they appear: anything starting
res://oruser://. A record addresses content inside its own package.
Size has ceilings too, per package and across all of them, and going over one is not always a refusal:
- ok Under the limit. Nothing is reported.
- warn Over the soft line and still loaded. The creator is told; the player is not stopped.
- degrade Over the limit on a dimension the host can reduce instead of refusing. What it fell back to is named, because a game quietly running differently is worse than one that says why.
- reject Over the limit on a dimension with no smaller version of itself. The package does not load and is named.
| Dimension | One package | All of them | Over it |
|---|---|---|---|
records | 20 000 | 120 000 | reject |
decoded_image_bytes | 256 MB | 1 GB | reject |
decoded_audio_bytes | 128 MB | 512 MB | reject |
map_cells | 4 194 304 | 8 388 608 | reject |
map_objects | 65 536 | 262 144 | reject |
effect_instances | 4 096 | 16 384 | degrade |
provider_frame_usec | 2 000 | 8 000 | degrade |
worker_tasks | 8 | 32 | reject |
queue_depth | 512 | 2 048 | degrade |
teardown_msec | 100 | 400 | warn |
An overhaul gets twice those numbers and a total conversion four times. Four, not unlimited: a bound a package can declare away is not a bound.
Only the removal line moves. A family takes a remove once it can hand the loader the record being withdrawn; two do today. Everything else on this table is permanent.
There is no scripting tier coming. The manifest carries a package type that ships behaviour instead of data: the only thing that could authorise running it refuses it, there is no sandbox behind it, and a package declaring it is refused today. Plan for data.
Read "data" widely. Numbers are data. Balance is data. What a weapon does, what a wound costs, what a good sells for, how a faction weighs a decision: all yours. What is not data is a mechanic the game has never had — that is code somebody writes and tests. If your idea needs one, it is a different game rather than a mod.
Saves
Your ids end up in the player's save file. A published id is a promise.
| Rule | Why |
|---|---|
| Never rename an id | Every save that used it is stranded. Add a new one and leave the old alone. |
| Uninstalling you never deletes a player's things | The save keeps ids it cannot resolve and reports them, so reinstalling gives the stash back. |
Raise version whenever you change anything | It is the thing a player can see. |
Every way a set of packages can differ from what a save was made under has a name, and each name decides one thing: whether the session may write over that file.
| Verdict | May write your save | What it means |
|---|---|---|
compatible | Yes | The composition matches what the save was made under. |
degraded | Yes | Understood, with named content absent. Play continues and the absence is reported. |
incompatible | No, the file is left alone | The session cannot honour what the save says. The original file is left alone. |
| What differs | Verdict | What it means |
|---|---|---|
identical_composition | compatible | Same packages, same versions, same composition. |
mod_added | compatible | A package the save never knew about. Nothing it recorded has moved. |
mod_missing | degraded | A package the save named is not installed. Its ids are still in the file and stay there. |
mod_upgraded_compatible | degraded | A package changed within its declared compatibility. Reported, because a changed hash is a changed answer even when it is a legal one. |
provider_contract_incompatible | incompatible | A capability the save relied on is answered by a provider whose contract moved. Continuing would be guessing what it means. |
conversion_missing | incompatible | The campaign itself is absent. Every id in the file belongs to a world that is not installed. |
safe_mode_disabled | degraded | The session started with packages off on purpose. The save is readable and its modded content is absent by choice. |
orphan_state_preserved | degraded | State whose package is gone rides through untouched. Preserved, never executed. |
Read the incompatible row twice: the original save is left alone. A file overwritten by a session that could not understand it is a lost run, and a player who loses one to a mod does not install another.
Every loaded package gets a fingerprint — the same across runs for the same manifest and content, different the moment any content file changes. The composed-set digest also includes each package id, version, load order and content digest, so the same packages in a different order cannot pretend to be the same composition. modding_fingerprint_report prints these values, and save schema v56 records the active fingerprint list so a later load can warn about a missing or changed package. Raising your version is still required because it is the value a player can see and compare before installing.
Sharing it
Zip the folder. That is the whole distribution format. There is no in-game browser; put it on Nexus Mods.
| Step | What |
|---|---|
| Check | The validator clean, --strict, before you upload. |
| Zip | The folder itself, so the archive holds my_mod/mod.json. |
| Version | Raise version in mod.json on every upload. |
| Requirements | List your dependencies on the Nexus page too. |
| Say | Which families you touch and which ids you override. |
Never change your package id. It is the namespace every one of your own ids carries, what another package names as a dependency, and what a save writes down. Renaming it makes every existing save think your package was uninstalled and a different one appeared beside it. Change the display name instead.
Tell people two things and you save more support questions than anything else: the game_version you built against, and whether you set canon_safe.
The version to write today is 0.0.0-dev. That is what this build answers to and what every example on this page declares, and it is one number rather than a range: when the game is released it changes here first.
Archives have bounds, decided from the header rather than after unpacking:
- 4 096 entries at most, none of them larger than 16 MB, and 128 MB unpacked in total.
- A name may be 200 characters and sit 8 folders deep.
- Nothing may claim to expand more than 200 times, which no honest content reaches: text compresses to about fifteen. A few kilobytes that unpack into gigabytes is the attack this number exists for.
- An entry that is not a plain file or a folder is refused: symlink, hardlink, device, fifo, socket. A symlink is the one that matters, because its name can be perfectly ordinary and following it writes wherever it points.
- An archive inside your archive is refused rather than measured (
.7z,.bz2,.gz,.pck,.rar,.tar,.xz,.zip). The outer one declares how far its entries expand; it cannot declare how far an inner one does.
There is no store to publish to today, and this is the shape of the one there will be. Distribution right now is the zip and the Nexus page above — nothing below is a field you fill in for an upload you can make this week. It is here because the fields are already declared in the build, so a store path is a matter of connecting them rather than of inventing them, and because a package written today will not need rewriting to travel that way.
A package that travels through a store carries more than the folder does:
| What it carries | Fields |
|---|---|
| Who it is | stable_id, version |
| Where it came from | author, license, source_url, package_hash |
| What a store says about it | content_rating, content_warnings, revocation_status |
| How it was delivered | store, store_id, subscription_id |
| State | Installs | What it means |
|---|---|---|
none | Yes | Nothing has been reported. |
reported | Yes | Somebody has reported it and nothing has been decided. The player is told and chooses. |
quarantined | No | Held pending a decision. It does not install and what is already installed is not deleted. |
revoked | No | Withdrawn. It does not install, and an installed copy is disabled rather than removed, because the player's saves still name its content. |
The two states that block an install still do not delete anything.
What a new build can do to your package
game_version is not decoration. The game compares what you built against with what it is:
| Verdict | What it means for you |
|---|---|
supported | The build you named is this one, or near enough. Nothing to do. |
older_build | You built against an older game. It still loads, and anything that moved since is where a surprise would come from. |
newer_build | You built against a game newer than the one running. The package is named as such rather than half-loaded on the chance that everything it uses happens to exist already. |
unreadable | The game_version you wrote is not a version. This one is always a typo. |
What it does about it depends on what moved:
| Class | What it means | What the game owes you |
|---|---|---|
| unchanged | Nothing a package can observe moved. | none |
| additive | Something new exists and everything that was there still means what it did. | a minor bump |
| breaking | A package written against the old surface can now be wrong without being edited. | a major bump |
A change counts as breaking for one of these reasons: field_removed, default_changed, type_changed, constraint_changed, required_added, operation_removed, root_changed, format_changed, id_field_changed, catalogue_changed, family_removed, contract_major_changed_without_new_id. default_changed is the one that looks additive and is not. A field whose default moves rewrites the behaviour of every package that never mentioned it, and none of their authors changed anything. constraint_changed covers bounds and enumerations, while root, format, id-field, other catalogue metadata and family removal changes alter where or whether a package can be read.
A name going away is deprecated before it is removed, and keeps working for at least 2 minor versions after the release that deprecated it.
The policy, in four sentences
Somebody deciding whether to spend a hundred hours on a package is really asking one question: what happens to it when the game updates. Here is the answer before there is a list to read.
| Question | Answer |
|---|---|
| Will my package still load? | Yes, unless a release is listed below as breaking, and no release is breaking without appearing there first. |
| Will players' saves survive? | Yes. A session that cannot understand a save refuses to write to it rather than overwriting it, and a save keeps ids it cannot resolve instead of dropping the things. Saves. |
| How much warning do I get? | A name is deprecated before it is removed and keeps working for at least 2 minor versions after the release that deprecated it. |
| How will I know? | The row lands in the table below, with what moved and what to change, before the release it belongs to. |
Releases that break packages
| Release | Date | What breaks | What to do |
|---|---|---|---|
| — | — | Nothing yet. No release has broken a package. | — |
The table is empty and it is not a placeholder. It exists now so that the first row has somewhere to land and so that the promise above is written down before it is tested rather than after. A row here names the release, the day it went out, exactly which shape stopped being accepted, and the edit that fixes a package — not "various changes".
Upcoming work that would break something goes in the same table, marked as upcoming, before it ships.
If the game will not start
A profile that fails to boot is retried a bounded number of times, then the game offers a way back in rather than trying forever.
A profile is retried 3 times. After that the game stops trying and offers one of these instead.
- retry Under the attempt bound. The same profile is tried again.
- last_known_good A profile that booted before is offered. Nothing is uninstalled.
- safe_mode No packages load for one session. The profile is untouched and returns when the player asks for it.
Where it failed tells you what to look at:
| Where it failed | What that points at |
|---|---|
discovery | Reading what is installed. A failure here is usually a malformed package. |
provider_start | Starting what the composition selected. A failure here names a capability. |
first_scene | The first scene the player would see. A failure here is content, not wiring. |
None of this uninstalls your package or edits your files. Safe mode is one session with no packages loaded, and your profile is waiting when you ask for it back.
A total conversion
Not a released creator path yet. The build understands the manifest and ships the Ashen Reach fixture; a public packaged workflow is not released. Treat the fixture as an engineering contract, not a promise you can ship one today.
Declared with type: total_conversion. fallback_policy beside it decides whether a half-installed conversion shows the game's own content or nothing.
| It replaces | It cannot add |
|---|---|
| The places, the peoples, the war, the myths, the map, the names for everything. Your own kingdoms, your own gods, your own economy. | A new kind of thing. A sword that hits harder is a number; a weapon that fires a beam and needs beam rules is a feature, and features are code. |
What changes, and what does not
Almost nothing changes. The folder is the folder, mod.json is mod.json, the families are the same families with the same fields, ids are namespaced the same way, the validator runs the same rules and the Mods screen reports the same states. Every tutorial on this page is a tutorial for building a piece of one. The distance between a package that adds a merchant and a package that is its own realm is a quantity of records, not a different skill.
Three things do change, and each is worth knowing before you start:
| What | How |
|---|---|
| The manifest | type: total_conversion, and fallback_policy beside it, which is vanilla or none and defaults to vanilla. vanilla means a half-installed conversion shows the game's own content where yours is missing; none means it shows nothing. |
| The budgets | Four times the ordinary profile — and a profile, not an exemption. The build's own words for it: "Four times, not unlimited: a bound that can be declared away is not a bound." If total_conversion removed the limits, it would become the line every package writes to escape them. |
| Untranslated words stop falling back to the game's | Ordinarily a missing key falls through player_locale, then your package's default language, then the game's own catalogue, then the key itself. A total conversion has that third rung removed. A key you have not written shows as the key, and is reported. |
That third one reads like a loss and is the opposite. Old Seat surfacing in a world that has no Old Seat is not a slightly wrong translation — it is the base game coming through the one you built over it. Removing the rung means a gap in your words is something you are told about instead of something a player finds.
Where this is going
Worth saying plainly, because it changes what is worth learning.
The engine underneath Bonemark was built to hold more than Bonemark. The setting is data: the places, the peoples, the economy, the myths and the words for all of it are records of the same kinds this page has been describing. That is why the total conversion type exists in the manifest at all, and why the build ships a second setting as a fixture rather than as a test of one line of code.
So the long shape of this is not "add a sword". It is that somebody who learns these families is learning how to build a world on this engine, and the distance between a package that adds a merchant and a package that is its own realm is a quantity of records rather than a different skill.
Steam Workshop is the direction for getting packages to players, which is why Sharing it describes store fields for a store that does not carry anything yet. Subscribing to a package instead of unzipping one changes how it arrives and nothing about how it is written: the same folder, the same mod.json, the same ids.
No date, on either of these, and no second setting named — one exists as an engineering fixture and that is not the same as one being announced. What is being said here is a direction and what it costs to be ready for it, which is: the thing you learn today does not stop being the thing when the door opens.
Owning a mod
The same terms every modding scene has settled on, because they are the ones that work. Nothing here is unusual and nothing here is a trap.
| Your mod is yours | The records you wrote, the pictures you drew, the sound you made. You keep them. Publishing a mod grants nobody a claim on your work. |
| The game's content stays the game's | Reference it all you like — override an id, patch a field, dress an item. Do not extract it, repackage it or ship copies of it inside your folder. This one is mostly kept for you: a package cannot shadow the game's own files, so the line is hard to cross by accident. |
| Copyright moves in neither direction | That is the legal name for the two rows above, and it is worth saying plainly because it is the thing people assume wrongly. Publishing a package does not sign your work over to the studio; installing one does not sign the game over to you. You hold copyright in the records you wrote and the art you made, and Bonemark's own records, art, sound and setting stay under the studio's. A total conversion is the case worth spelling out: replacing the setting a player sees does not license the setting it replaced, and a package still cannot carry the game's files. |
| Mods are free | No sale, no paywall, no early access behind a payment, on any store or platform. Not ours to sell either: the same rule binds the studio. |
| Donations are fine | A tip jar, a Patreon, a thank-you page. That is support for a person, not a price on a mod, and every healthy scene allows it. |
| Somebody else's work needs their permission | A font, a sound, a sprite, a set of records from another package: ask, and credit them where a player can read it. |
| Translations are allowed by default | Anyone may translate your package and publish the translation, crediting you. Say so on your page if you would rather they asked first. |
| Reuse asks first | Building on somebody's package, continuing an abandoned one, lifting their records into yours: ask them. Silence is not a yes. |
Why free is not negotiable. Paid mods have been tried at scale and taken back down every time: the moment money is in it, the arguments are about who owns which idea rather than about what to build next, and the scene that was sharing starts holding things back. Everyone in this ecosystem is on the same footing, and that includes the people who made the game.
One thing we can ask for. A package taken down, and only for the narrow reasons: it ships the game's files, it impersonates somebody, or it carries content that is illegal where it is hosted. Not because we disagree with it.
Settled by the studio 2026-08-14. The wording here is the policy; a lawyer reads it before modding opens publicly, and if that changes a word this page changes with it.
Where to ask
Discord, and it is not open yet. That is where modding questions will be answered, and the invitation is not written anywhere because there is nothing to invite anyone to. The Discord mark on the front page is dark for the same reason; the day it lights up is the day this line carries the address.
Saying "Discord" before the address exists is worth more than saying nothing: it tells you where to look and it tells you not to go hunting through a forum that was never opened.
In the meantime the thing that answers most questions with nobody else involved is Before you ask anyone for help: the preflight block names the build, the packages, the states and the reason behind every refusal, and most of what a person would have replied is already in it.