← Bonemark

Modding

Add data-only content by writing small text files. No programming or compiler is needed. In five parts: installing somebody else's mod, your first mod, changing the game that is already there, a reference with every family and every diagnostic code, and living with it. The table of what you can make is measured from the build rather than promised.

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 itSo 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 gamePackages are read at start-up. There is no live install.
3. Switch it onOpen Mods and use the switch. A copied folder is found and left off — nothing loads because it appeared in a folder.
4. Restart againThe 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 screenIn the reportWhat it means
LoadedactiveLoadable, no blockers.
Not validinvalidManifest carries blocked script/exe files.
Needs another modskipped_missing_dependencyA declared dependency is absent or disabled.
Needs another modskipped_incompatible_dependencyA declared dependency is outside its range.
SkippedincompatibleThe mod targets a newer game build.
In conflictconflictTwo loadable mods target the same override id (still loadable; load_order wins, both flagged).
OffdisabledYou 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 loadYou switched it on and nothing has read it yet. Its content is not in the game until you restart.
Off — restart to unloadYou 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 rejected

Nine 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.

WhatWhy notWill it change
The quests familyIt 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 audioAudio 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 fontsFont descriptors and locale coverage are validated, but no running theme consumes a package font yet.Yes
A packaged total conversionThe 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 featureSettled 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 runsA 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.

WordWhat it means
PackageYour mod. One folder with your name on it.
Manifestmod.json, at the top of that folder. Who you are, what you bring.
RecordOne thing you add. One sword is one record.
FamilyA kind of thing. Swords, helmets and bread are all items. Each family has its own folder.
Idyour_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 toGo to
Build something start to finishStart from what you want to make — the six tutorials, one package
Get one file loading in ten minutesA mod in ten minutes
Look up one family's fields and an exampleThe families, one by one
See everything you are allowed to makeEvery family
Change something the game already shipsChange what the game already has
Add your own art, sound, lettering or colourPictures, sound, lettering, colour
Draw a place in TiledMaps of your own
Write a questQuests of your own
Work out why nothing showed upNothing showed up
Look up an error codeEvery diagnostic code
Know what a mod can never doWhat a mod can never do
Not break somebody's saveSaves
Put it where people can get itSharing 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 addingThe families that do itWhere they are written up
Trade and goodsgoods, vendor_inventories, caravans, recipesThings, trade and the numbers behind a fight
Things a player carriesitems, combat_balanceThings, trade and the numbers behind a fight
Peoplenpc_definitions, mercenary_templates, noble_houses, relationships, perks, enemy_archetypes, enemy_variants, group_archetypesPeople, houses and what they can do
Places and buildingsbuildings, pois, maps, world_generationThe world and the places in it
Words a player readslocales, dialogue, scenes, lore, mr_lines, quest_plans, quest_metadata, progressStory, quests and the words a player reads
How it looks and soundsimages, audio, fonts, themes, effects, presentation_bindings, screensHow it all looks and sounds
Controls and difficultyinput_bindings, settings, adaptiveWhat the player controls, and what watches them

What you need

The gameRequiredNo Godot, no compiler, no build step.
A JSON-aware text editorRequiredVS Code, Sublime, Notepad++ — all free. Windows Notepad works and will not help you.
A pixel-art editorOnly for your own artAseprite (~20 €), or free: LibreSprite, Piskel, Krita, GIMP.
An audio editorOnly for your own soundAudacity. Short effects from nothing: sfxr, ChipTone.
TiledOnly for your own mapsFree. 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 toGo toHow long
Add something to buy and sellA good to sell15 minutes
Add something a player carriesAn item to carry10 minutes, after the one above
Add a person who sells itA merchant who stocks it30 minutes
Add a door in a settlementA building service20 minutes
Add a place on the world mapA place on the map20 minutes
Change something the game already hasChanging what is already there15 minutes
Draw a map, write a quest, ship artMaps of your own, Quests of your own, Pictures, sound, lettering, colourlonger, and all three are live families
Ship the quests family, a packaged total conversion, or a package that is a featureNot 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 hadNothing 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 sellsCoinAnd
Clay, stone, reeds1the floor. Dug or cut, no work in it
Grain, wood2
Candle3one evening of light
Wool, fish, grapes4
Bread6a day's food
Salt, honey, rope8
Cloth (bolt)10woven from 2 wool and 1 reeds
Refined iron, salted fish12
Black pepper14an import, and it tastes like one
Leather, cheese15
Wine25
One-handed weapons50–300the band, not a single value
Two-handed weapons150–600
Heavy plate500–1500the 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.json

Start 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"]
  }
]
FieldWhy this value
idYour 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_nameWhat 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.
categoryMANUFACTURED, 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_tierPRESERVED — 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_price9. 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.
weight0.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_classWhich trade makes it. chandler is the one the reference package uses.
daily_consumption_classWho burns through it. household means towns consume it steadily, which is what keeps a merchant restocking.
tagsFree 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.json

A 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"]
  }
]
FieldWhy this value
idIts 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.
categoryMATERIAL, which is what the game's own candle item is. Not CONSUMABLE: nothing is eaten.
rarityCOMMON. Rarity is what loot tables and shops read when they roll; a common thing turns up.
weight0.4, the same as the good. When the two disagree the player's pack and the caravan disagree about the same object.
max_stack20, 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_value10 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.
sellableTrue, 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.json

This 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
      }
    ]
  }
]
FieldWhy this value
settlement_idUse 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_idTogether they turn a person into a shop. Either one without the other is a person who cannot sell or a stall nobody minds.
price_multiplier1.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_rate0.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_stock6. 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_days2, 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_standing0: anyone may buy. Above zero is stock she keeps for people she likes.
kindWhat 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.json

You 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"
  }
]
FieldWhy this value
labelThe words on the door. Left out, the game capitalises your id, which reads like an id.
roleWhat 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.
colorBeeswax 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_serviceshop — 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.json

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, (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
  }
]
FieldWhy this value
kindRUINS — 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_radius2.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_modifier0.0 — it does not help you see. Leave a number at zero rather than dropping the field when zero is a decision.
one_time_lootFalse: 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 file

Two 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" }
    ]
  }
]
ChoiceWhy
patch, not replacereplace 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 → 4One 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 tagsThe 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 positiontags.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 addedWhere it turns upThe catch
goodsOn 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_inventoriesThe 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.
itemsWherever 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_definitionsThe 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.
buildingsThe 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.
poisOn 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

FieldWhat it does
idIn the example above. Your package name. Must match your folder exactly, and it is the word before the colon in every id you write.
nameIn the example above. What a player sees. Capitals and spaces are fine here.
versionIn the example above. Yours to choose. Raise it whenever you change anything.
game_versionIn the example above. The build you made it for.
content_typesIn the example above. The families you are bringing. A family missing from this list is never read, whatever is in the folder.
dependenciesIn the example above. Packages yours needs. A name, or an object with optional: true.
load_orderIn the example above. Who goes first when two packages touch the same thing. Lower loads earlier.
overridesIn 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_safeIn the example above. Whether your mod fits the game's own story. This is you telling players, not the game deciding.
rootsA family's folder, when you want it somewhere other than the default in The families, one by one. It stays inside your package.
manifest_versionWhich manifest shape you are writing. Change what the game already has's operations need 2. This build reads up to 2.
typeWhat 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.
capabilitiesWhat a package would ask the host for, if the tier above ever opened. An unknown field here fails closed rather than being ignored.
trust_tierUntrusted, 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.
supersedesPackages this one means to overrule. A field claimed this way stops being reported as a clash. An unknown field here fails closed too.
fallback_policyWhat a package falls back to when something it supersedes is absent. One of vanilla, none.
campaignThe campaign a conversion owns.
campaign_packageDeclares 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 quest

Names 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.description

Keys 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 thisLanguageColumn header
enEnglishenthe source language
itItalianoit
frFrançaisfr
deDeutschde
esEspañoles
pt-BRPortuguê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
RuleWhy
Every key is in your namespacenorthern_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 refusedNot 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 answerIt 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 bothA 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 toOperationWhat you need
Add something newaddA namespaced id of your own. Nothing else.
Rewrite one of the game's recordsreplaceThe record's id listed in overrides. A full record — it inherits nothing.
Change one field and leave the restpatchoverrides, plus "manifest_version": 2 in mod.json.
Withdraw a recordremoveoverrides, 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 }
    ]
  }
]
OperationWhat it doesIt needsIt refuses
addSupply a complete new record under your own namespace.namespaced idan id that already exists, whether vanilla or from another mod
replaceSupply a complete record for an id that already exists, having declared it.declared override, complete recordan id absent from the registry, and an id the manifest does not list under overrides
patchChange named fields of an existing record and leave the rest alone.declared override, changesan id absent from the registry, a field the family does not allow, and any change form outside patch_operations
removeWithdraw an existing record and leave a tombstone in its place.declared override, removable familyan id absent from the registry, and any family not marked removable below

A patch names its changes one at a time:

  • set — Write value at field, whatever was there before. Written with field and value.
  • unset — Drop field from the record, so the reader sees whatever the family's default is. Written with field.
  • append_unique — Add value to the list at field if it is not already there. Written with field and value.
  • remove_value — Drop every occurrence of value from the list at field. Written with field and value.
  • Name a field by path, never by position. tags.0 is 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_unique is 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 aboutSettledTestsValues
packagesat loadall_of, none_ofmod ids loaded beside yours
campaignat loadis, any_of, not_any_ofthe campaign the run started from
localeat loadis, any_of, not_any_ofthe language being played in
seasonwhile playingis, any_of, not_any_ofspring, summer, autumn, winter
weatherwhile playingis, any_of, not_any_ofa weather id the game rolled
day_phasewhile playingis, any_of, not_any_ofdawn, day, dusk, night
daywhile playingat_least, at_mosthow many days the run has lasted
TestTakesTrue when
isone namethe answer is exactly that
any_ofa listthe answer is one of them
not_any_ofa listthe answer is none of them
at_leasta numberthe answer is that or higher
at_mosta numberthe answer is that or lower
all_ofa listevery one is present
none_ofa listnone 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 spring

A 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.

FamilyremoveWhat happens
goodsyesWithdrawn. Still resolves by id, gone from what the game offers.
vendor_inventoriesyesErased. The row is dropped.
everything elsenoRefused, 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 clashesWhat happens
Different fields of one recordBoth apply. Nothing is lost.
The same plain fieldLater load_order wins, and an undeclared clash is an error: undeclared_collision, and the composition is not ok.
Two packages supplying the whole recordThe same. The winner is decided by load_order and the plan still refuses until somebody declares it.
The same list, via append_unique / remove_valueNo conflict. Both say what the list must contain, not what it must be.
One package withdraws a record, another patches or replaces itThe withdrawal stands. The later operation is refused, whatever the load order.
Two packages dressing one subjectLater load order wins, loser named in the report.
A package dressing another package's contentRefused.

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.

KindFilesInstalledWhat that means
image.png, .webpYesThe file is read and the texture is built.
audio.ogg, .wavNoDescriptors 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, .otfNoDescriptors 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
FamilyFolderAcceptsRequired fields
imagescontent/assets/images.png, .webpid, file, format
audiocontent/assets/audio.ogg, .wav (uncompressed)id, file, codec, role
fontscontent/assets/fonts.ttf, .otfid, file, format, sizes, covers, fallback_font
themescontent/assets/themescolour tokensid, tokens
effectscontent/assets/effectshost templatesid, 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. format is what the file is, not what the extension claims.
  • A file name containing .. or ~, or starting /, \, res:// or user://, 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:

FamilyFieldWhat it is
itemsicon_idThe icon on an item you ship.
npc_definitionsportrait_idA named NPC's portrait.
perksiconA 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"
  }
]
SlotYou shipWhere it appearsalternative_keyDrawn
portraitimageThe dialogue frame, by the speaker's id.requiredyes
spriteimageA troop token, world-map ground, or a building in a settlement.yes
iconimageInventory cells, for any item — yours or the game's.requiredyes
bannerimageThe house's colours on its journal page.yes
voiceaudioPlays when that speaker's line appears.requirednot yet
use_soundaudioPlays when an item is used and the use took effect.not yet
ambienceaudioLoops while the player stands on that ground.not yet
themethemeWhich of your palettes the interface wears.yes
fontfontWhich of your faces the interface wears.not yet
effecteffectWeather 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.

RoleForSlider
musicTracksMusic
ambienceRooms, weather, placesAmbient
sfxShort effectsEffects
voiceSpoken linesEffects

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 registerPixel 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.
Tiles16 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 colourThe 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"
  }
]
RuleWhat it means
The kit16-pixel tiles, orthogonal, right-down, finite.
Layers are roles, not namesground, collision and walkable are required. A layer called collisions is refused rather than ignored.
Geometry is declaredCollision and walkable area are objects you draw, never derived from what the tiles look like.
Every object name is namespacednorthern_reach:towpath, not towpath.
world_originWhere 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 whatA 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 nothingA layout that refuses places nothing at all.
No pathsA 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.

KindWhat it is
arcThe story. Lists the quests in it.
questThe job: who gives it, what it is called, what it pays.
objectiveOne thing the player has to do.
endingHow 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"]
  }
]
GoalWatched today
collect_itemyes — you are carrying enough of something
defeat_unityes — you have killed enough of something
earn_coinyes — you have enough money
reach_placeyes — you have visited a settlement
survive_daysyes — enough days have passed
speak_to, deliver_item, defend_place, escort_unit, learn_loreno — refused by name rather than accepted and never completable
RewardHanded over today
give_item, receive_coinyes
change_relationyes, when the subject is the player
the other fiverefused 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 a kind. kind says which of the four records this is. A reward written with kind reads 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.

CheckTakesAsksAnswered today
companion_presentcompanionThat companion is standing there to hear it.yes
faction_stancefaction, stanceA faction stands where the option expects.yes, for hostile — any other value is refused before the first line
has_coincoinThe player can afford it.yes
has_itemitem, countThe player is carrying it.yes
has_knowledgeloreThe player has been told this.yes
has_traittraitThe player was built that way.yes
lacks_knowledgeloreThe player has not been told this yet.yes
quest_statequest, stateA quest stands where the option expects.yes
relation_at_leastsubject, target, regardOne party thinks at least this much of the other.yes
seasonseasonIt is that part of the year.yes
time_of_dayphaseIt is that part of the day.yes
CommandTakesDoesPerformed today
change_relationsubject, target, deltaMove a standing, within the host's published relationship rules.yes
give_itemitem, countHand something over.yes
grant_knowledgeloreTell the player something.yes
open_shopvendorPut a vendor's stock on screen.yes
pay_coincoinThe player pays.yes
receive_coincoinThe player is paid.yes
recruit_companioncompanionAdd somebody to the party when the host's recruitment rules permit it.yes
set_quest_statequest, stateMove a quest to a state its own record allows.yes, for active, failed — any other value is refused before the first line
start_combatencounterEnd the talking.yes
take_itemitem, countTake something away.yes
travel_todestinationMove 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_coin or has_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" }
    ]
  }
]
BeatTakesTimingPlayed today
ask_nameprompt_keythe scene waits for ityes
endthe scene carries onyes
fade_insecondsthe scene waits for ityes
fade_outsecondsthe scene waits for ityes
lock_playerthe scene carries onyes
narrateline_key, secondsthe scene waits for ityes
play_cuecuethe scene carries onyes
remove_actoractorthe scene carries onyes
sayactor, line_keythe scene waits for ityes
show_exitthe scene carries onyes
start_dialoguegraphthe scene waits for ityes
unlock_playerthe scene carries onyes
waitsecondsthe scene waits for ityes
walk_toactor, anchorthe scene waits for ityes
  • A scene that takes the controls must give them back. A sequence ending without unlock_player is 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.json

The 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.

#SymptomWhat the validator says
1The folder path is wrongWARN: 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.
2The id has no colonERROR: add needs an id under your own namespace. frost_blade is refused; northern_reach:frost_blade is accepted.
3The JSON is brokenERROR: invalid json goods.json: Expecting ',' delimiter: line 6 column 5. One missing comma makes the whole file unreadable, and you are told the line.
4The file is in the wrong packageThe 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.
5A required field is missingThe 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=1

A 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.

FamilyStatusFolder in your packageReport commandWhat it is
adaptiveReady to usecontent/player/adaptivemodding_adaptive_reportProfiles that watch how a run is going and suggest a different setting for it.
audioValidated, nothing consumes it yetcontent/assets/audiomodding_audio_reportSounds 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.
buildingsReady to usecontent/world/buildingsmodding_building_reportBuilding and settlement-service definitions: label, colour, role and a reused interior.
caravansReady to usecontent/logistics/caravansmodding_caravan_reportTrade 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_balanceReady to usecontent/combat/balancemodding_combat_balance_reportBounded combat balance values that tune existing resolver parameters without adding rules, operations or mechanics.
dialogueReady to usecontent/narrative/dialoguemodding_dialogue_reportConversations as records: the graph, its beats and the choices standing on them.
effectsReady to usecontent/assets/effectsmodding_effect_reportWeather, dust, sparks and lights, composed out of the templates the host implements.
enemy_archetypesReady to usecontent/threat/enemy_archetypesmodding_enemy_archetype_reportKinds 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_variantsReady to usecontent/combat/enemy_variantsmodding_enemy_variant_reportStat 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.
fontsValidated, nothing consumes it yetcontent/assets/fontsmodding_font_reportTypefaces a package ships, named by id: the file, the sizes it renders at, and the languages whose glyphs it carries.
goodsReady to usecontent/economy/goodsmodding_goods_reportTradeable goods and their economic properties.
group_archetypesReady to usecontent/strategic/group_archetypesmodding_group_archetype_reportStrategy tunables for a group that lives on the world map: alarm radius, target preference, member counts, supply and retreat thresholds.
imagesReady to usecontent/assets/imagesmodding_image_reportImages a package ships, named by id: icons, portraits and sprite sheets, each pointing at a file inside its own folder.
input_bindingsReady to usecontent/inputmodding_input_reportActions of a package's own, with the key it would like.
itemsReady to usecontent/inventory/itemsmodding_items_reportInventory items, including weapons and armour.
localesReady to usecontent/localemodding_locale_reportThe 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.
loreReady to usecontent/narrative/loremodding_lore_reportCodex entries: places, people, events, customs and things a world tells stories about.
mapsReady to usecontent/world/mapsmodding_map_reportLayouts 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_templatesReady to usecontent/character/mercenariesmodding_mercenary_reportHireable mercenary blueprints: name, class, background, starting health and morale, hire cost and the day they first appear.
mr_linesReady to usecontent/medieval_realism/structuredmodding_mr_lines_reportStructured medieval-realism lines, addressed by file and row id rather than by a JSON id field.
noble_housesReady to usecontent/politics/noble_housesmodding_noble_house_reportNoble house seed data: the lord, the heir, which settlements answer to the house, and who it counts as rival or ally.
npc_definitionsReady to usecontent/narrative/npcsmodding_npc_reportNamed NPC definitions.
perksReady to usecontent/character/perksmodding_perk_reportDeclarative perks: triggers and effects, with no script handler.
poisReady to usecontent/world/poismodding_poi_reportPoints of interest placed on the authored world map by coordinate.
presentation_bindingsReady to usecontent/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.
progressReady to usecontent/player/progress-Player progression records: achievements, safe difficulty profiles and run profiles within host rules.
quest_metadataReady to usecontent/narrative/quest_metadatamodding_quest_metadata_reportQuest metadata records, keyed by quest_id rather than id.
quest_plansReady to usecontent/narrative/quest_plansmodding_quest_plan_reportQuest plans as records: the arcs, quests, objectives and endings a package writes out and the game builds into quests of its own.
questsChecked, not installed yetcontent/narrative/quests-Authored quest resources, loaded by path rather than parsed as records.
recipesReady to usecontent/economy/recipes-Crafting recipes: what is consumed, what comes out, where the work happens, what it costs and how likely it is to fail.
relationshipsReady to usecontent/companion/relationships-Companion templates, bounded relationship policies, recruitment terms and declarative bonds.
scenesReady to usecontent/narrative/scenesmodding_scene_reportStaged scenes: the room, who is standing in it and the beats that play out in order.
screensReady to usecontent/ui/screensmodding_screen_reportScreen layouts: which sections stand in which panel of a screen the game already has, and in what order.
settingsReady to usecontent/settingsmodding_settings_reportOptions a player can change, declared rather than drawn.
themesReady to usecontent/assets/themesmodding_theme_reportInterface palettes as tokens: named colours, sizes and spacings, with the pairs whose contrast a player has to be able to read.
vendor_inventoriesReady to usecontent/world/vendor_inventoriesmodding_vendor_inventory_reportVendor stock tables.
world_generationReady to usecontent/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 beWhere it would goWhat it is
feature_extensionno folder yetA 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:

AnswerWhat the save does
preserve_inert_referenceThe id stays, resolves to nothing, and is reported. What the player had is still named.
substitute_placeholderA stable engine-owned stand-in answers so the world still loads, and the original id is kept alongside it.
drop_optional_presentationThe 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 id field
  • 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.

FieldRequiredValues
idyestext, id
roleyesstring
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 id field
  • 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.

FieldRequiredValues
idyestext, id
origin_settlement_idyesstring
destination_settlement_idyesstring
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 id field
  • 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.

FieldRequiredValues
idyestext, id
fileyestext, fixed shape
name_keyyesstring
world_originyeslist 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 id field
  • 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.

FieldRequiredValues
idyestext, id
display_nameyesstring
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 id field
  • Report none of its own; modding_preflight_report covers 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.

FieldRequiredValues
kindyesany
idyestext, id
streamyestext, id
tablesyesobject
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 id field
  • 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.

FieldRequiredValues
idyestext, 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 id field
  • 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.

FieldRequiredValues
idyestext, id
archetype_idyesstring
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 id field
  • 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.

FieldRequiredValues
idyestext, 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 id field
  • 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.

FieldRequiredValues
idyestext, id
display_nameyesstring
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 id field
  • 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.

FieldRequiredValues
idyestext, 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 id field
  • 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.

FieldRequiredValues
idyestext, id
display_nameyesstring
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 id field
  • 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.

FieldRequiredValues
idyestext, id
skillyestext, fixed shape
name_keyyesstring
kindyesroad, junction, lateral, capstone
colyesinteger 0 to 4
rowyesinteger 1 to 7
levelyesinteger 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 id field
  • Report none of its own; modding_preflight_report covers 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:

FieldRequiredValues
idyestext, id
kindyesbond
relationyesstring
subjectyesstring
targetyesstring
initial_regardyesinteger -100 to 100
policyyesstring
description_key-string
name_key-string
note_key-string

A record with kind: companion_template:

FieldRequiredValues
idyestext, id
kindyescompanion_template
unityesstring
recruityesstring
loyaltyyesstring
description_key-string
leave_below-integer -100 to 100
name_key-string
starting_regard-integer -100 to 100

A record with kind: recruit_terms:

FieldRequiredValues
idyestext, id
kindyesrecruit_terms
meansyesstring
cost-integer 0 to 1000000
description_key-string
name_key-string
requires_bond-string

A record with kind: relationship_policy:

FieldRequiredValues
idyestext, id
kindyesrelationship_policy
relation_kindsyeslist of string
boundsyesobject
bandsyeslist 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 id field
  • 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.

FieldRequiredValues
idyestext, id
valuesyesobject

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 id field
  • 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.

FieldRequiredValues
idyestext, 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 id field
  • 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.

FieldRequiredValues
idyestext, 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 id field
  • Report none of its own; modding_preflight_report covers 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.

FieldRequiredValues
idyestext, id
inputsyesobject
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 id field
  • 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.

FieldRequiredValues
idyestext, id
display_nameyesstring
entriesyeslist 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 id field
  • 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:

FieldRequiredValues
kindyesdialogue_graph
idyesstring
entryyesstring
description_key-text, fixed shape
name_key-text, fixed shape
speaker-string

A record with kind: dialogue_node:

FieldRequiredValues
kindyesdialogue_node
idyesstring
graphyesany
voiceyesline
speakeryesstring
line_keyyesany
conditions-any
description_key-any
name_key-any
reveals-any

A record with kind: dialogue_node:

FieldRequiredValues
kindyesdialogue_node
idyesstring
graphyesany
voiceyesnarration
line_keyyesany
conditions-any
description_key-any
name_key-any
reveals-any

A record with kind: dialogue_node:

FieldRequiredValues
kindyesdialogue_node
idyesstring
graphyesany
voiceyesend
conditions-any
description_key-any
line_key-any
name_key-any
reveals-any

A record with kind: dialogue_option:

FieldRequiredValues
kindyesdialogue_option
idyesstring
nodeyesstring
label_keyyestext, fixed shape
gotoyesstring
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.

ColumnRequiredValues
keyyestext, 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 Brina

lore

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 id field
  • 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.

FieldRequiredValues
idyestext, id
categoryyesplace, person, event, custom, thing
title_keyyesstring
body_keyyesstring
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.

ColumnRequiredValues
line_idyestext, 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_only

progress

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 id field
  • Report none of its own; modding_preflight_report covers 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:

FieldRequiredValues
idyestext, id
kindyesachievement
name_keyyesstring
description_keyyesstring
conditionyeslist of object
campaign-string
hidden-boolean
icon-banner, blade, coin, crown, hand, road, …

A record with kind: difficulty_profile:

FieldRequiredValues
idyestext, id
kindyesdifficulty_profile
name_keyyesstring
modifiersyesobject
campaign-string
description_key-string
permadeath-boolean

A record with kind: run_profile:

FieldRequiredValues
idyestext, id
kindyesrun_profile
name_keyyesstring
difficultyyesstring
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_id field
  • 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.

FieldRequiredValues
quest_idyestext, 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 id field
  • 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:

FieldRequiredValues
kindyesany
idyestext, id
stagesyeslist
description_key-text, fixed shape
name_key-text, fixed shape

A record with kind: ending:

FieldRequiredValues
kindyesany
idyestext, id
arcyesstring
requiresyeslist
description_key-text, fixed shape
epilogue_key-text, fixed shape
name_key-text, fixed shape

A record with kind: objective:

FieldRequiredValues
kindyesany
idyestext, id
questyesstring
goalyescollect_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:

FieldRequiredValues
kindyesany
idyestext, id
name_keyyestext, fixed shape
giveryesstring
objectivesyeslist
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_report covers 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 id field
  • 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.

FieldRequiredValues
idyestext, id
beatsyeslist 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 id field
  • 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.

FieldRequiredValues
idyestext, id
fileyestext, fixed shape
codecyesogg, wav
roleyesmusic, 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 id field
  • 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.

FieldRequiredValues
idyestext, id
templateyesdust_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 id field
  • 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.

FieldRequiredValues
idyestext, id
fileyestext, fixed shape
formatyesttf, otf
sizesyeslist of integer 8 to 96
coversyeslist
fallback_fontyesstring
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 id field
  • 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.

FieldRequiredValues
idyestext, id
fileyestext, fixed shape
formatyespng, 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 id field
  • Report none of its own; modding_preflight_report covers 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.

FieldRequiredValues
idyestext, id
subjectyestext, id
kindyesbuilding, faction, interface, item, region, tile, …
slotyesambience, banner, effect, font, icon, portrait, …
cueyestext, 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 id field
  • 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.

FieldRequiredValues
idyestext, id
screenyescharacter_sheet
panelsyesobject

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 id field
  • 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.

FieldRequiredValues
idyestext, id
tokensyesobject
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 id field
  • 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:

FieldRequiredValues
kindyesany
idyestext, id
name_keyyestext, fixed shape
policyyesenemy_composition, event_cadence, hint_frequency, pace_economy, pace_encounters, weather_variety
readsyeslist
fallback_policyyesenemy_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:

FieldRequiredValues
kindyesany
idyestext, id
profileyestext, id
thresholdsyeslist 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_id field
  • 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.

FieldRequiredValues
action_idyestext, id
label_keyyesstring
contextyesexploration, combat, menu, global
suggested_keyboardyesstring
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 key field
  • 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.

FieldRequiredValues
keyyestext, id
typeyesbool, int, float, string, enum
defaultyesany
scopeyesinstallation, campaign, presentation
label_keyyesstring
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.

CommandThe same thing on the Mods screen
modding_open_folderthe path at the top, and the button beside it
modding_manifest_reportthe state column, and the line under each row
modding_<family>_reportthe counts under each package, and Show details
modding_preflight_reportCheck compatibility
modding_roots_report, modding_safety_reportCopy report
modding_reloadNo 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 load

modding_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.

CommandWhat it tells you
modding_roots_reportData mod roots and fingerprints
modding_manifest_reportMod states (active/invalid/skipped/conflict)
modding_fingerprint_reportDeterministic save fingerprints (save_wired=no)
modding_safety_reportSecurity posture (behavior mods off, blocked files)
modding_preflight_reportGREEN/RED verdict over the active mod set
modding_packaging_reportTier 2 PCK/ZIP detection (disabled by default)
modding_campaign_reportModding_campaign_report [id] - read-only installed campaigns; with an id, switch to it
modding_condition_reportWhat the world publishes for record conditions
modding_reloadModding_reload [all] - read the packages again; all only with no run standing
modding_binding_reportPresentation bindings bound/refused/superseded
modding_open_folderCreate (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 items

Writes 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" --json

The 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-baseline

This 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>" --json

Reads 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-runtime

The 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.

CodeWhat caused itWhat to do
adaptive_brings_a_modelA 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_backA 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_invalidThe 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_budgetA 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_keyA 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_stepA 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_referenceA 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_referenceA 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_leakedTeardown 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_aspectA 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_budgetThe 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_refusedNothing 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_affordanceAn 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_invalidA 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_mismatchThe 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_missingThe 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_missingNo 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_refusedA 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_usedA 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_refusedA 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_overranA 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_invalidA 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_alternativeInformation 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_budgetA 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_usedA 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_recordedAn 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_offeredA 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_unwritableThe 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_rejectedA 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_offeredUnder 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_offeredThe 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_exceededThe 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_degradedA 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_exceededOne 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_warningOver 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_dimensionUsage 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_profileA 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_identityA 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_invalidThe 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_foundA 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_handlerPublic 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_invalidThe 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_missingA 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_unenterableA 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_propertyA 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_formulaA 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_expiredA 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_invalidThe 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_budgetA 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_unreachableA 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_operationAn 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_statusA 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_referenceA 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_referenceAn 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_allowedA 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_unclassifiedA 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_fileA 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_familyAn 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_missingA 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_familyA 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_familyThe 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_leakAn 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_expressionA 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_endA 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_invalidThe 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_nodeA 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_budgetA 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_keyA 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_revealA 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_stepA 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_referenceA 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_mismatchThe 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_invalidThe 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_offlineProvenance 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_reportedSomebody 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_revokedThe 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_deferredA 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_refusedA 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_invalidThe 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_boundsA 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_overriddenA 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_referenceA 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_sourceA 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_degradedAn 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_droppedAn 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_refusedAn 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_fieldThe 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_namespaceThe 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_fieldA 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_incompleteRemoving 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_unboundedA 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_capabilityThe 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_autoloadThe 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_fallbackA 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_refusedA 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_refusedA 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_gridA 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_mismatchBytes 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_usedA 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_refusedAn 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_cancelledA 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_mismatchThe 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_fieldThe 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_refusedThe 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_fieldA 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_rejectedThe 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_unboundedA 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_formatThe 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_platformThe 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_conflictThe 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_orphanedA 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_actionA 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_fieldThe 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_namespaceThe 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_unreachableThe 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_fieldAn 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_chordA 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_contextThe 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_invalidThe 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_definitionA 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_replacedA 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_referenceA 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_textText 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_dataA 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_pathA 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_anchorTwo objects claiming one id.Two anchors share a name, so which one a thing attaches to depends on read order. Rename one.
map_invalidThe 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_mismatchA 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_missingA 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_failedThe 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_packageA 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_budgetA 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_referenceA 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_subsetSomething 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_surfaceAn 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_collisionA 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_failedThe 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_vanillaA 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_requiredThe 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_invalidThe 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_lostAn 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_budgetA 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_outsideA 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_namespaceA 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_keyA 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_stepA 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_referenceA 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_capabilityA 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_refusedA 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_inactiveThe 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_capabilityA 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_kindA 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_untrustedThe 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_requiredThe 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_appliedA 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_expressionA 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_unreachableAn 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_invalidThe 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_appliedA 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_budgetA 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_keyA 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_unfinishableA 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_stepA 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_referenceA 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_gapA 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_codeA 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_stateA 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_invalidThe 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_unreachableA 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_lostThe 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_collisionAn 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_relationA 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_referenceA 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_invalidatedEntries 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_missingNothing 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_budgetA 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_refusedA request this build will not make.Read the rule the report names and change the id to something that place accepts.
resolution_restart_requiredA 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_tombstonedA 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_absentThe 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_matchesNothing the save recorded has moved.Nothing to fix. The packages this save was made with are the packages that are installed.
save_content_absentContent 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_carriedState 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_movedA 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_originalThe 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_missingThe 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_madeA 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_modePackages 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_preservedA 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_invalidThe 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_migratedA 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_missingA 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_requiredThe 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_withheldA 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_refusedA 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_unresolvedA 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_pathA 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_invalidA 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_absentA 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_budgetMore 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_keyA 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_serviceA 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_referenceA 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_leakAn 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_pathA 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_invalidThe 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_definitionA definition asking to run something.Remove the field. Definition adapters describe; they do not hold the authority to change what they describe.
society_relationship_collisionThe 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_sidedA 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_referenceA 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_ignoredThe 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_malformedA 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_orphanedNo 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_rejectedThe 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_reservedA package claimed a namespace the host owns.The namespace is one the host owns. Pick your own package's.
state_schema_futureState 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_migratedState 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_oldState 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_namespaceA 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_policyA 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_invalidThe 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_budgetA 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_keyA 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_termA 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_referenceA 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_weightlessA 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_executableA 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_lowA 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_refusedA theme this build will not register.Read the property the report names and put it inside what it allows.
tombstone_blocks_campaignThe 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_substitutedAn 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_recordedAn 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_recoveredThe 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_referenceSomething 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_codeA 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_categoryA 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_invalidThe 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_behaviourA 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_referenceA 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_missingAn 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_inconsistentA 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_controlA 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_randomnessA 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_invalidThe 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_referenceA 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 codeScripts, extensions, executables, shell files — anywhere in your package.
No scenes or resource filesA record pointing at one is refused even if the file is not there.
No shadowing base filesA mod adds and changes records; it cannot replace the game's own files.
No new kinds of thingA new perk yes, a new kind of perk no.
No controls, autoloads or project settingsNot reachable at all.
No deleting outrightremove 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:// or user://. 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.
DimensionOne packageAll of themOver it
records20 000120 000reject
decoded_image_bytes256 MB1 GBreject
decoded_audio_bytes128 MB512 MBreject
map_cells4 194 3048 388 608reject
map_objects65 536262 144reject
effect_instances4 09616 384degrade
provider_frame_usec2 0008 000degrade
worker_tasks832reject
queue_depth5122 048degrade
teardown_msec100400warn

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.

RuleWhy
Never rename an idEvery save that used it is stranded. Add a new one and leave the old alone.
Uninstalling you never deletes a player's thingsThe save keeps ids it cannot resolve and reports them, so reinstalling gives the stash back.
Raise version whenever you change anythingIt 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.

VerdictMay write your saveWhat it means
compatibleYesThe composition matches what the save was made under.
degradedYesUnderstood, with named content absent. Play continues and the absence is reported.
incompatibleNo, the file is left aloneThe session cannot honour what the save says. The original file is left alone.
What differsVerdictWhat it means
identical_compositioncompatibleSame packages, same versions, same composition.
mod_addedcompatibleA package the save never knew about. Nothing it recorded has moved.
mod_missingdegradedA package the save named is not installed. Its ids are still in the file and stay there.
mod_upgraded_compatibledegradedA package changed within its declared compatibility. Reported, because a changed hash is a changed answer even when it is a legal one.
provider_contract_incompatibleincompatibleA capability the save relied on is answered by a provider whose contract moved. Continuing would be guessing what it means.
conversion_missingincompatibleThe campaign itself is absent. Every id in the file belongs to a world that is not installed.
safe_mode_disableddegradedThe session started with packages off on purpose. The save is readable and its modded content is absent by choice.
orphan_state_preserveddegradedState 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.

StepWhat
CheckThe validator clean, --strict, before you upload.
ZipThe folder itself, so the archive holds my_mod/mod.json.
VersionRaise version in mod.json on every upload.
RequirementsList your dependencies on the Nexus page too.
SayWhich 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 carriesFields
Who it isstable_id, version
Where it came fromauthor, license, source_url, package_hash
What a store says about itcontent_rating, content_warnings, revocation_status
How it was deliveredstore, store_id, subscription_id
StateInstallsWhat it means
noneYesNothing has been reported.
reportedYesSomebody has reported it and nothing has been decided. The player is told and chooses.
quarantinedNoHeld pending a decision. It does not install and what is already installed is not deleted.
revokedNoWithdrawn. 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:

VerdictWhat it means for you
supportedThe build you named is this one, or near enough. Nothing to do.
older_buildYou built against an older game. It still loads, and anything that moved since is where a surprise would come from.
newer_buildYou 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.
unreadableThe game_version you wrote is not a version. This one is always a typo.

What it does about it depends on what moved:

ClassWhat it meansWhat the game owes you
unchangedNothing a package can observe moved.none
additiveSomething new exists and everything that was there still means what it did.a minor bump
breakingA 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.

QuestionAnswer
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

ReleaseDateWhat breaksWhat 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 failedWhat that points at
discoveryReading what is installed. A failure here is usually a malformed package.
provider_startStarting what the composition selected. A failure here names a capability.
first_sceneThe 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 replacesIt 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:

WhatHow
The manifesttype: 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 budgetsFour 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'sOrdinarily 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 yoursThe 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'sReference 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 directionThat 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 freeNo 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 fineA 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 permissionA 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 defaultAnyone may translate your package and publish the translation, crediting you. Say so on your page if you would rather they asked first.
Reuse asks firstBuilding 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.

Reviewed 2026-08-16 against the build. Every label above is a status, not a promise.Read the labels →