mod.json: the mod manifest
Every mod has a mod.json describing what it is, what it needs, and what it patches.
Minimal manifest
{
"schemaVersion": 1,
"id": "my-mod",
"name": "My Mod",
"version": "1.0.0",
"entrypoint": "entrypoint.js",
"targets": [">=0.6.0 <0.7.0"]
}All fields
{
"schemaVersion": 1, // required; always 1 today
"id": "cool-cars", // required; lowercase [a-z0-9-], globally unique
"name": "Cool Cars", // required; display name
"version": "1.0.0", // required; semver
"entrypoint": "main.js", // required; ES module, default export = factory or class
"targets": [">=0.6.0 <0.7.0"], // required; PolyTrack version ranges (OR'd); [] = no claim
"description": "…",
"authors": ["alice"], // string or { name, contact }
"license": "MIT", // SPDX id
"icon": "icon.png", // shown on the mod's portal card (see below)
"homepage": "https://…", // the repo or project page; shown as a "site" link on the card
"docs": "https://…", // usage documentation for players; the card's "docs" button opens this
"environment": "*", // "*" | "web" | "desktop" | "worker"
"depends": { "some-lib-mod": "^1.0.0" },
"recommends": {},
"suggests": {},
"conflicts": {},
"breaks": { "cool-cars-old": "*" },
"provides": [],
"mixins": [ { "config": "mixins.json", "environment": "web" } ],
"physics": "physics.json", // f32 constant rewrites for the physics binary; one path, not a list
"capabilities": ["dom", "storage"], // warn-only labels
"vanillaSafe": true, // false if the mod touches physics/timing
// (declaring `physics` warns regardless of what this says)
"custom": {} // arbitrary tooling / inter-mod data
}The icon
icon puts an image on the mod's card in the portal, in place of the default letter tile. Three shapes work, matching how the mod got into the portal:
- A path relative to
mod.json(for example"icon.png"), for mods added by URL. It resolves against the manifest URL the same wayentrypointdoes, so an icon sitting next to the manifest in the repo just works. - An absolute
https://URL, hosted anywhere that serves images. - A
data:image/*URI, for pasted mods. A pasted mod's only copy lives in the browser, so there is no host to serve a relative path from; inline the image instead, or use an absolute URL.
The tile is 30 by 30 pixels, so a small square image (64 to 128 pixels) is plenty. If the field is missing, the URL is not an image, or the image fails to load, the card falls back to the letter tile. Non-image schemes are ignored, and the icon never affects whether the mod loads.
The links: docs and homepage
Two separate fields, because they answer different questions:
docsis for the player: the page that explains how to use the mod. The card's docs button opens it. There is no fallback: if a mod only setshomepage, no docs button appears, because a repository landing page is not documentation.homepageis the mod's repository or project page. The card shows it as a site link.
Both must be plain http(s) URLs; anything else is ignored.
The entrypoint contract
An ES module whose default export is either:
- a factory:
(api, game) => { ...; return disposer? }(the common form), or - a class extending
TspmlModwith lifecycle methods (preInit/init/ready/onUnload).
The loader calls it with the api object. The export is always default; there are no magic export names.
Environment, targets, and soft-disable
TSPML never hard-aborts a boot over a bad mod. Three manifest facts can soft-disable a mod: it's excluded from the load order and reported with a typed warning, while everything else loads normally:
| Condition | Warning |
|---|---|
targets ranges don't match the running game version | incompatible-target |
environment names a different concrete host (portal = web) | environment-mismatch |
breaks names an installed mod at a matching version | breaks-disabled |
"*" (or omitting the field) means no constraint. Disables cascade: a mod that depends on a disabled mod is disabled too, with its own report.
mixins[].environment is enforced separately by the host applying the patches: the web portal skips configs declared for desktop/worker and says so in the UI.
Dependencies
Relations between mods, with npm semver ranges:
| Field | Effect |
|---|---|
depends | Must be present and satisfied, or this mod won't load |
recommends | Loads without it; warns |
suggests | Informational |
conflicts | Both load; warns |
breaks | If the named mod is installed at a matching version, this (declaring) mod is soft-disabled |
provides | This mod satisfies dependencies on another id |
Load order is an automatic topological sort with cycle detection and explicit conflict reporting.
Three special dependency ids resolve against the host, and all three work in the portal and the dev harness: polytrack (the running game version, the same fact targets is checked against), tspml (the loader version), and tspml-api (the mod-facing API version). The portal reports 0.5.0 for both TSPML ids, so "depends": { "tspml-api": "^0.5.0" } is satisfied, and a range naming a version TSPML doesn't have is refused with a reason rather than loaded in silence.
Both are pre-1.0 deliberately: the mod-facing surface isn't frozen. Under 0.x semver the minor bump is the breaking one, so ^0.5.0 and ~0.5.0 mean the same thing today.
includes is parsed but not implemented
The nested-mod field validates cleanly and emits an unsupported-includes warning; the nested mod will not load. Ship it separately and use depends. (#16)
physics: rewriting constants in the physics binary
PolyTrack's driving model lives in polytrack_physics.wasm, a compiled binary fetched as its own file. It never passes through the bundle transform, so for most of TSPML's life "you can't change how the car drives" was simply true. physics is the field that changes that.
It's a path, relative to mod.json, to a file that rewrites f32 constants inside that binary:
{
"wasmHash": "d4ef02676973d41afc34b23b5248f6950b35dc4cc7e3047e3a9c6bd88e4c180e",
"patches": [
{
"name": "grip",
"signature": "d0d92e0ad4a721e9efa44a4930f3a21e93e5bfedb236243f65516379c2a8adca",
"oldValue": 1.100000023841858,
"newValue": 1.4
}
]
}One path, not an environment-scoped list like mixins: there's one physics binary and one all-or-nothing apply, so a per-host variant would have nothing to vary. A host that can't patch the binary skips the file rather than applying part of it.
Deriving one with find-constant
You don't write signature or wasmHash by hand, and you can't: a signature is a hash of the containing function's structure. The find-constant command searches a physics binary by value and reports every place that value occurs:
$ pnpm --filter @tspml/wasm build # once; the command runs the compiled package
$ pnpm --filter @tspml/wasm find-constant 1.1
fetching https://app-polytrack.kodub.com/0.6.2/polytrack_physics.wasm
binary sha256: d4ef02676973d41afc34b23b5248f6950b35dc4cc7e3047e3a9c6bd88e4c180e
searching for f32 1.100000023841858
1 occurrence, 1 patchable:
✓ [0] function 234 — value 1.100000023841858
signature d0d92e0ad4a721e9efa44a4930f3a21e93e5bfedb236243f65516379c2a8adca
17 f32 constants in this function, payload at 0x2c2e6It fetches the live binary by default and never writes those bytes to disk. --wasm <path> reads a local copy; --version picks a build. Add --emit <name>=<value> to print a ready-to-paste file, with the JSON alone on stdout:
$ pnpm --filter @tspml/wasm find-constant 1.1 --emit grip=1.4 > physics.jsonThree things it deliberately won't do:
- It never picks for you. Which constant governs grip is a question about the game's physics, not about the binary. Every hit is reported; none is ranked.
--emitrefuses when more than one candidate is patchable. - It refuses what the loader would refuse, at authoring time rather than mid-race: a value occurring twice inside its own function (
oldValuecan't say which site), or a function whose structure matches another function (no signature can name it). Both are common — about 2% of functions are structurally ambiguous, and the clamp idiom puts-10and+10in one body. - It reports the f32, not what you typed.
1.05is stored as1.0499999523162842. Copy the printedvalueintooldValue; a double literal that merely looks right fails the exactly-once check.
If a search comes back empty for a value you can see in a disassembly, that rounding is usually why.
Two gates, both fail-closed
Between a patch and a write stand two independent checks:
wasmHashpins the exact bytes the patches were derived against. If the served binary isn't those bytes, nothing is applied and the portal says so. A new PolyTrack release trips this by design.- Each
signatureis re-derived structurally from the binary in hand — from the set of float constants and the histogram of opcodes in a function, never from an offset or an index. A signature matching zero functions, or more than one, is refused.
They're independent on purpose. A pin can't tell you a signature still names exactly one function; a structural match can't tell you the author ever saw this build.
Why structural rather than by byte offset: an offset that goes stale doesn't fail to match, it writes a float into whatever moved into that address. Refusing is the only safe answer, and a fingerprint can refuse. Measured on the shipped 0.6.2 binary, 535 of 549 functions (97.4%) are uniquely identified this way.
Application is all-or-nothing. One failing patch means vanilla bytes, with a reason, rather than a half-tuned sim.
f32 only
f32.const is the whole of it. There's no integer, f64, or byte-sequence patching, and none planned. A physics constant worth tuning is a float in practice, and every extra payload type is another way to write wrong bytes into a running binary. A float rewrite in place is the one edit that can't change the size or structure of the binary around it.
Limits and conflicts
16 patches per mod, 32 across all enabled mods. When several mods declare physics, the portal merges them into one plan and names every mod it left out, with the reason:
| Reason | What happened |
|---|---|
malformed | The mod's physics.json doesn't parse under the current rules |
hash-conflict | It pins a different binary than the first mod in the merge did |
duplicate-target | It patches a constant an earlier mod already claimed |
over-cap | Adding it would push the merged plan past 32 patches |
Exclusion is per mod, not per patch: the earlier mod keeps its patches and the later one is left out whole, rather than both being refused or one silently winning half a plan. Whichever mods do apply, the game is told — and so are you.
It's always a leaderboard risk
Declaring physics marks the mod as a leaderboard risk whatever vanillaSafe claims, because rewriting a constant changes how every lap time is produced. That's the one label in TSPML not taken at face value from the manifest. It's still warn-only, as everywhere else — the player decides. See Safety & fairness.
Capabilities & vanillaSafe: honest labels
capabilities and vanillaSafe are warn-only, consented-advisory labels, not an enforced sandbox: in a same-realm page, JavaScript has no ambient-authority isolation, and TSPML won't claim one it can't back. They exist so players get an honest, visible signal; declare them truthfully. See Safety & fairness.