13 KiB
Contributing to the mod platform
Two routes
| You are... | Lane | Review bar |
|---|---|---|
| adding a mod to the gallery, or listing one in the showcase | Lane A | template + polish checklist + green modkit validate |
| changing the loader, a registry schema, an event/hook name, or a manifest field | Lane B | RFC + backward-compat statement + parity test + generated docs |
If you are not sure which lane you are in, ask this: could my change make somebody else's existing mod behave differently? If yes, it is Lane B.
Route A — contributing a mod
1. Scaffold
python3 tools/modkit.py scaffold my_mod --profile content
--profile is one of content, overhaul, total_conversion. The
scaffold refuses to overwrite an existing directory, and prints the next
commands.
Or copy the gallery entry closest to your intent — that is what the gallery is for:
| You want to... | Copy |
|---|---|
| change numbers | mods/examples/example_balance_tweaks |
| change art | mods/examples/example_shiny_palette |
| add music or cries | mods/examples/example_jukebox |
| add a quest, NPC or dialogue | mods/examples/example_lost_parcel |
| change how battles work | mods/examples/example_weather |
| add a screen or a tool | mods/examples/example_dexnav |
| build a whole new game | mods/examples/example_mini_conversion |
2. What the PR must contain
-
A green
modkit validate. CI runs it; so should you.python3 tools/modkit.py validate mods/examples/<id> --base imported python3 tools/modkit.py lint mods/examples/<id>validatedrives the real loader headlessly, so a mod that passes here does not surface load errors in game.--base importedfolds against the full vanilla id space; without it, rules that can only be decided against real Red content (MK103, the patch-target check) are reported as skipped rather than guessed at. -
A
tests/directory with at least one suite that loads the mod through the headless loader and asserts its stated effect — not just that it loaded.package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.modkit") local Data = require("src.core.Data"); Data:load() local run = T.sdk.loadMod("mods/examples/my_mod", { data = Data }) T.eq(#run.errors, 0, "loads clean") T.eq(Data.pokemon.PIKACHU.baseStats.speed, 120, "the patch landed") run.release() T.finish("my_mod")Add a
.modkitignorelisting the suite so it stays out of the distributed package — a test requiring engine modules is a private-require finding against the shipped archive, andpacktreats warnings as fatal. -
A
README.mdthat opens with one sentence saying what the mod does, names its persona, and gives the three commands to try it. No prerequisites the scaffold did not already create. -
A
mod.cardmeeting the §3.2 schema:return { summary = "One sentence, <=100 chars.", author = "Your handle", -- never blank; no author is anonymous by omission tags = { "balance", "beginner" }, differences = { changed = {…}, added = {…}, known = {…} }, credits = { { who = "…", for_ = "original chiptune arrangement" } }, compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 }, } -
A
CHANGELOG.mdin keep-a-changelog format, with a heading matchingmanifest.version.validatewarns when the version advanced without one. -
Disabled by default. Gallery entries live in
mods/examples/, which the loader's one-level discovery does not walk, so a fresh install discovers none of them and the vanilla game is unchanged. -
No ROM-derived bytes. Art and audio ship as originals or as a
transforms.luaoperating on the player's own cache.modkit lintis the hard floor; see the legal posture.
CI checks 1, 2, 6 and 7 mechanically. A reviewer checks 3, 4, 5 and the polish checklist.
3. Category
manifest.category is a closed vocabulary. An unknown value is a warning,
not a hard error, so the list can grow without breaking old mods.
| category | Meaning | Typical profile |
|---|---|---|
TWEAK |
Small data edits: stats, prices, learnsets, encounter tables | content |
BALANCE |
Systematic rebalance across many records or a ruleset | content / overhaul |
CONTENT |
New species / moves / items / maps / trainers | content |
QUEST |
New story, NPCs, dialogue, cutscenes | content |
MECHANIC |
New or changed battle/field mechanics via hooks/effects | overhaul |
GRAPHICS |
Sprite / tileset / palette / font changes | content |
AUDIO |
Music, sfx, cries | content |
UI |
New or modified screens, menus, overlays | content / overhaul |
TOOL |
Dev/QoL utilities, overlays, inter-mod libraries | content |
TOTAL_CONVERSION |
Full re-theme; owns its own tri-ledger | total_conversion |
OTHER |
Fallback | any |
GAMEPLAY is accepted as an alias for TWEAK, so example_mew_starter
keeps validating with the value it has shipped since before the taxonomy
existed.
4. mod.card
The manifest is the engine's contract: identity, load order, dependencies, permissions, profile. The card is the human-facing one: who made this, what it changes, what it does not do yet. It is never read by the loader's merge — only by tooling and the manager's detail pane — so an absent or malformed card can never break a load.
Two fields deserve their own note:
differencesis a self-declared tri-ledger, mirroring the discipline the engine holds itself to.changedandaddedlet a player see the blast radius before installing;knownis where you are honest about what is rough. A card with an emptyknownon a complex mod reads as carelessness, not polish.screenshots[].transformdescribes a screenshot by the driver script that regenerates it from the player's build, rather than shipping the pixels. That is the legal posture extended to your marketing: a distributed mod never carries ROM-derived bytes, not even in its preview images.
5. Tags
Lowercase kebab strings, open vocabulary. The showcase generator
lowercases and de-dupes. A recommended starting set: beginner,
data-only, quality-of-life, hardcore, cosmetic, story, ruleset,
audio, ui, total-conversion.
Route B — contributing an engine / mod-API change
Changing the loader, a registry schema, an event or hook name, or a manifest field touches the compatibility surface the project promises to hold stable. Those PRs carry five obligations.
1. An RFC
docs/rfcs/NNNN-<slug>.md, covering:
- Motivation — the mod that cannot be written today.
- The decision it extends or amends — name the D-number and the plan file, so the change is traceable to the design it modifies.
- The exact API delta — new registry names, new schema fields, new event/hook names and their payload shapes and call sites.
- A migration note for existing mods — what an author has to do, if anything. "Nothing" is a valid and preferred answer.
2. A backward-compatibility statement
Show that the v1 surface still works: content.X:register/override/get,
events:on, hooks:wrap, mod.log, mod:read, the manifest v1 fields,
and pokemon.before_give.
A change that would break a v1 mod is rejected unless it is
additive-with-alias. mods/example_mew_starter is the live proof: it is
api 1, uses category = "GAMEPLAY", copies a whole species record because
patch did not exist yet, and it must keep loading unchanged.
3. A parity-guarantee test
Two tests, not one:
- The no-mod test — vanilla behavior is unchanged with nothing
installed. A new hook with no subscriber must return the vanilla value;
a new registry must be a provable no-op when empty; a new event must not
allocate its payload when nothing wants it (
Runtime.wants(name)/Runtime.wantsHook(name)guard the hot paths). - The mod-API test — the new seam, exercised through the public mod API rather than by reaching into internals. If the test has to require a private module to drive your seam, the seam is not finished.
4. Docs with the change
The reference pages are generated from src/mods/Schemas.lua, so a new
registry or a new schema field lands with its catalog entry in the same PR
and the generator runs clean:
luajit tools/gen_registry_docs.lua # in-repo default
luajit tools/gen_registry_docs.lua ../project.wiki # the wiki checkout
The prose reference lives in the GitHub wiki; the generated pages are written into a checkout of it, so they cannot drift from the engine.
5. Deprecation etiquette
Nothing is removed. A superseded seam is marked deprecated in the generated reference with its replacement named, keeps firing and working, and is listed in the deprecations page.
pokemon.before_give is the worked precedent: the pokemon.give hook
supersedes it, and it is grandfathered forever anyway.
Review
PRs touching src/mods/, src/mods/Schemas.lua, or the event/hook catalog
need the RFC label and a green parity gate before merge.
The polish checklist
Every gallery example and every community mod the guide recommends meets
this bar. [auto] items are checked by modkit validate; [review] items
by a human.
Error messages
-
[auto]No bareerror()orassert()in mod callbacks. Every failure path usesmod.log:warn/mod.log:error— the loader already prefixes[modid]— and names a remediation:-- no local mew = assert(mod.content.pokemon:get("MEW"), "Mew is missing") -- yes if not mod.content.pokemon:get("MEW") then mod.log:warn("MEW missing from the merged view -- is a species mod " .. "loaded before this one? speed patch skipped") return end -
[review]Every registration is validated against its schema, so a typo is a load-time message naming the field, not a nil-index crash three screens later.
Empty states
[review]Every screen a mod adds renders a sentence when its data set is empty — "No songs registered", "Nothing seen yet" — never a blank box.ListMenugives you this for free.
First-run experience
[review]The README opens with one sentence of what the mod does, then the commands to try it.[auto]The mod loads clean on a fresh install — zeroLoader.errors— with only its declared dependencies.[review]Options have sane defaults, so the mod does something useful before the player opens its options pane.
Credits and honoring authors
[review]mod.card.creditsnames every upstream contribution — art, music arrangement, borrowed code — and what it was for. A mod that ports another community work credits it and links it.[auto]mod.card.author(orauthors) is present and non-empty. The showcase and the manager both surface it, so no author is anonymous by omission.[review]Asset provenance is honest: originals declared original, cache-derived output produced by a declared transform, third-party assets credited and license-compatible.
Legal posture (non-negotiable)
[auto]No ROM-derived bytes in the packaged mod.modkit packrefuses otherwise, andpackrunsvalidate --strict, so even warnings block the archive.[review]A total conversion carries the TC legal callout: the Red import still runs and supplies fallback infrastructure, the conversion overrides on top, and it distributes recipes rather than extracted content.
Versioning etiquette
Three version numbers coexist.
Engine version — src/core/Version.lua. Major = a breaking change to
the mod-facing schemas or API; minor = new backward-compatible seams;
patch = bugfix.
Mod API version — the integer modApi, currently 2. Bumped only on a
breaking change to the mod object surface. A manifest's api field pins
the surface the mod was written against, so an api-2 mod keeps working when
the engine ships api 3.
Your mod's version — the manifest version, semver:
| bump | when |
|---|---|
| patch | data fixes; no save-shape change, no new content ids |
| minor | new content ids, new options with defaults, new optional deps |
| major | removed or renamed content ids, a changed mod.save shape (needs a mod.migrations:add(sinceVersion, fn) entry), or a raised game_version floor |
Declare the engine range you target in game_version (a semver range, e.g.
">=1.0.0 <2.0.0"). The loader checks it on load; a mismatch is a clear,
mod-attributed manager error, never a silent partial load.
Every version change gets a CHANGELOG.md heading. modkit validate warns
when manifest.version advanced without one.