Skip to content

mod.json: the mod manifest ​

Every mod has a mod.json describing what it is, what it needs, and what it patches.

Minimal manifest ​

json
{
  "schemaVersion": 1,
  "id": "my-mod",
  "name": "My Mod",
  "version": "1.0.0",
  "entrypoint": "entrypoint.js",
  "targets": [">=0.6.0 <0.7.0"]
}

All fields ​

jsonc
{
  "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 way entrypoint does, 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.

Two separate fields, because they answer different questions:

  • docs is 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 sets homepage, no docs button appears, because a repository landing page is not documentation.
  • homepage is 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 TspmlMod with 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:

ConditionWarning
targets ranges don't match the running game versionincompatible-target
environment names a different concrete host (portal = web)environment-mismatch
breaks names an installed mod at a matching versionbreaks-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:

FieldEffect
dependsMust be present and satisfied, or this mod won't load
recommendsLoads without it; warns
suggestsInformational
conflictsBoth load; warns
breaksIf the named mod is installed at a matching version, this (declaring) mod is soft-disabled
providesThis 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:

json
{
  "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 0x2c2e6

It 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.json

Three 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. --emit refuses 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 (oldValue can'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 -10 and +10 in one body.
  • It reports the f32, not what you typed. 1.05 is stored as 1.0499999523162842. Copy the printed value into oldValue; 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:

  • wasmHash pins 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 signature is 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:

ReasonWhat happened
malformedThe mod's physics.json doesn't parse under the current rules
hash-conflictIt pins a different binary than the first mod in the merge did
duplicate-targetIt patches a constant an earlier mod already claimed
over-capAdding 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.

TSPML is a fan-made tool. It never redistributes PolyTrack; the portal transforms your own live copy of the game.