sindicate
v0.21.0
Published
Sindicate — open-world game engine for three.js WebGPU. Streaming worlds, characters, mounts & vehicles, missions, crowds.
Maintainers
Readme
SINDICATE
Open-world game engine for three.js WebGPU. Streaming worlds, characters, mounts & vehicles, missions, ambient crowds — extracted from a shipped browser western (Dustwater) and consumed as an npm package by every game built on it.
- Design & migration plan:
ENGINE_DESIGN.md— the adversarially-reviewed blueprint. Read it first. - Contracts:
docs/contracts/— the written interface each subsystem exposes and each game implements. - Reports from games:
ISSUES.md— what is wrong.UPDATES.md— what to add. See Reports from games below; this is how the engine actually improves. - Consumption: games depend on
"sindicate": "^X.Y.Z"from npm. Source ESM, no build step;threeandthree-mesh-bvhare peer dependencies. - The rule: engine code changes only in this repo — never copy engine files into a game to hack them.
Depend on the published package, not a
file:link. A link always resolves to whatever is in your working copy, so it can never catch a problem with what actually ships — a different surface: the filespackage.json'sfilesselects, reached through itsexportsmap. 0.17.0 published aReferenceErrorthat everyfile:-linked game was blind to, because their working copy was fine. All four games now take the tarball.
What's in the box
Migration steps 1–2 (of the plan in ENGINE_DESIGN.md §9): the verified-clean world/systems tier plus the complete core pipeline — rendering, assets, animation, retargeting, input — extracted from western, with game data arriving through registration calls instead of imports. Full reference: docs/api/.
Core
- assets — Synty FBX/GLB loading, atlas binding with shared cached materials, multi-skeleton pack collapse, clip cooking to JSON, texture warm-up.
- anim —
Animator/RawAnimatorstate machine: auto retargeting, bone-masked overlay layer, per-clip timing trims, safe one-shot callbacks. - retarget — bind-pose-delta retargeting so clips authored on one rig play on differently-named rigs.
- input — keyboard + mouse + gamepad behind one API with edge detection, data-driven pad bindings, analog sticks.
- renderer — WebGPU with WebGL2 fallback, opt-in TRAA, post-processing with a single game-supplied TSL screen-grade slot.
- quality — low/medium/high presets, per-game persistence, live apply, one-way FPS
AutoTuner. - utils & decals — shared math/noise helpers plus
DecalField, a ring-buffer-budgeted surface decal system.
World
- collision — merged-static
MeshBVHcapsule collision, built sync or in a Web Worker for streamed tiles. - grass — streaming instanced grass clumps: GPU wind, trampling with spring-back, dryness/wetness/snow.
- water — see-through low-poly water: scene refraction with Beer–Lambert absorption, fresnel sky reflection.
- wind & weather uniforms — the single source of weather truth: CPU
Windsim + shared TSL uniform singletons.
Systems
- shatter — pooled prop debris: real shard meshes flung, bounced, settled and faded, pure CPU, nothing resident.
Vendor
- FBXLoader — vendored three.js FBXLoader patched to merge all FBX animation layers; pinned against three ^0.184.
Callable API
Games plug in at explicit registration points — all boot-time, before first use (see each doc's gotchas for ordering):
import {
setClipDefaults, setAtlasEmissiveHook, setQualityStorageKey,
Input, DEFAULT_PAD_BINDINGS, createRenderer, createPostProcessing, wantsTRAA,
} from 'sindicate';
// Per-game quality storage key — BEFORE the first getQuality read
// (games sharing an origin in dev otherwise overwrite each other).
setQualityStorageKey('mygame_quality');
// Game animation data: per-clip timing trims + which source rig authored each clip.
setClipDefaults({
timing: { swing: { start: 0.12, speed: 1.4, end: 0.85 } },
rigs: { swing: 'syntyKnight' },
});
// TSL emissive term added into every atlas material — BEFORE the first
// applyAtlas/instantiate (materials are cached).
setAtlasEmissiveHook((albedo) => lampFieldTerm(albedo));
// Unified input with the game's own pad layout (data, not code).
const input = new Input(canvas, { padBindings: DEFAULT_PAD_BINDINGS });
// Renderer + post pipeline; the grade is the game's one screen-look slot,
// driven live via post.gradeAmount.value — never rebuilt at runtime.
const { renderer } = await createRenderer(container);
const post = createPostProcessing(renderer, scene, camera, {
traa: wantsTRAA(),
grade: Fn(([color, amount]) => /* identity at amount 0 */ color),
});Games on Sindicate
| Game | Role | Consumes |
|---|---|---|
| western (Dustwater) | the parity benchmark — the engine was extracted from it, so anything it does differently is a regression | ^0.18.0, branch sindicate-port |
| fable (Aldenvale) | the generality test — same engine, nothing in common with a western | ^0.18.0, branch sindicate-port |
| MilitaryGame (Blacksand) | the third consumer, and the one that finds things: Synty + Kubold + Kevin Iglesias packs, where western's assumptions about clip completeness, weapon count and audio layout are all false | ^0.18.0 |
| TakeTheCity | born on the engine — no western lineage at all, so it exercises the road, city and vehicle tiers cold | ^0.18.0 |
Reports from games
A game that hits an engine limitation writes it up rather than patching around it. Two files, kept in this repo so the engine owns its own backlog:
ISSUES.md— what is wrong. Defects, and places where "what western's assets look like" got encoded as "what an asset pack looks like".UPDATES.md— what to add. Every item additive: an engine default good enough to be the demo, plus an override point so a game can change it without forking.
What a good report contains
The reports that have actually moved this engine all share a shape, and it is worth copying:
- Provenance per item, because it decides how much to trust it.
[live]— hit while running, diagnosed to a line, workaround in place.[verified]— re-checked against the current source while writing.[doc]— from a doc read, not re-checked. A[doc]item gets confirmed against source before anyone touches it; three of them turned out to be in a different file from the one named. - The line, and the mechanism. Not "animations break on weapon switch" but "
registerMasked()snapshotsactions[srcName]and nothing invalidates it". - The symptom that was actually observed, especially when it looks unrelated to the cause. "Thrown grenades hung in mid-air with frozen fuses" was an unguarded optional-chain in footsteps, four systems away. That distance is the most useful thing in the report.
- Whether western is affected, argued rather than assumed.
- A suggested order, because the reporter knows which one is costing them time today.
The rules a fix is held to
- The default is today's behaviour. Register nothing, change nothing — that is what makes a
list like this safe to land against two shipped games. Returning
nullfrom an override means "engine decides" for the same reason. - Override decisions, not methods.
override('Animator.play', fn)only legalises the fragility:play's signature is implementation shape and will move. "What should the body be playing?" is a question, and questions have stable contracts. Every design gap inISSUES.mdPart 2 is a question the engine answered with a hardcoded string. - Fail at registration, not at frame 900. An unknown name or a wrong type throws at construction, where the stack trace points at the mistake.
- Verify by behaviour, not by syntax.
node --checkpasses on an undeclared identifier — it is a runtimeReferenceError. Two regressions shipped that way. And a test that watches what was asked for is blind to what the ask did: one fix was verified by checking the call and was still wrong, because the resulting animation looped for ever. Drive the real per-frame sequence and watch the result over more than one clip length. - Exporting a default is half its value. A default you cannot read is a magic number; one you
can import, inspect and spread from is documentation that cannot drift —
DUSTWATER_SFX(dirs)over a list buried in a loader. - Schedule the big ones deliberately. N-slot weapons and the seat/occupant abstraction are both correctly deferred: landing them opportunistically is how a parity benchmark moves without anyone deciding it should.
The defaults doctrine
From UPDATES.md U0, and the bar for anything new: can a new game run without supplying this,
and is replacing it one declaration? Three kinds of default, and picking the wrong one is how
engines get heavy:
- D1 — code defaults. Every tunable gets a working value; nothing is required just to construct. Ship everywhere, no exceptions.
- D2 — contract defaults. For anything whose real payload is a file, ship the contract and never the bytes: the bone-name map, the clip-name convention, the socket names, the expected directory shape. The mesh is not the hard part and cannot legally ship anyway; the rig map is, and it is pure data.
- D3 — procedural placeholders. Where a default would have to be a binary, generate one in code. A blockout humanoid weighs nothing and cannot 404 — and it is the honest way to build an AI system, because a behaviour bug should be visible without any art at all.
