Docs
/

@leumas/game-kit

The Leumas game simulation kernel: ECS, fixed-step loop, physics seam, prefabs, defineGame, netcode. Isomorphic — runs identically in the browser and in a Node sim worker. Contains no three.js, no...

@leumas/game-kit

The Leumas game simulation kernel: ECS, fixed-step loop, physics seam, prefabs, defineGame, netcode.

It is isomorphic, and that is the point. Everything here runs unchanged in a browser tab and in a Node sim worker, so the authoritative server and the predicting client execute the same rules over the same components. There is no three, no DOM, no React and no clock anywhere beneath any entry point. pnpm check:game fails the build if that stops being true.

Presentation lives in @leumas/game-3d. Games live in @leumas/games. Neither belongs here.

The five things worth knowing

1. The public surface is five factories, deliberately. Every JSDoc block in this package is read by check:props-coverage, the function index and the MCP schema generator. A kernel of two hundred loose functions would be two hundred tool entries no agent could choose between, so the kernel exports createWorld, createLoop, defineComponent, defineGame and createPrefabPack, and everything else is a documented key on a returned handle.

2. Entity ids are packed, and the generation bits are load-bearing. An id is (generation << 20) | index. When an entity is destroyed its slot is recycled with generation + 1, so a stale id fails world.alive(e) instead of silently addressing whoever moved in. Eleven bits of generation keeps an id inside a positive Int32, which is what lets it live in an eid component field and leaves -1 free for NO_ENTITY.

3. headless is the entire client/server delta. It skips the interpolate and present phases and nothing else. A server is not a different world; it is the same world with two phases off. The matching rule on the content side is that resolvePrefab strips Render when headless — which is what lets one prefab file feed both sides without a build step or a naming convention to drift.

4. The loop truncates; it does not catch up. Past maxCatchUp steps the accumulated time is thrown away and onStarve fires. A four-second GC pause must cost four seconds of simulated time. Draining the debt means 240 late steps, each making the next frame later still — that is the spiral of death, and admitting the time is gone is the only reliable escape. On a server, onStarve belongs on a dashboard: a room that starves is a room whose players are being cheated, and it is otherwise invisible.

5. world.rng is the only randomness. A Math.random() inside a system desyncs client from server — both run the same code over the same inputs, get different numbers, and diverge silently over minutes rather than failing at once. Use rng.fork(label) for a new subsystem so adding one does not shift every other system's stream and break a replay that used to pass.

Entry points

SubpathWhatStatus
.ECS, loop, prefabs, defineGame, rng, mathshipped
./characterthe pure-JS controller prediction replays, plus static box collisionshipped
./physicsthe Rapier seam — dynamic-imports the WASM, returns opaque handlesshipped
./netprotocol, snapshot/delta, interest, interpolation, predictionshipped
./aisteering, flow fields, sensingP7
./worldchunk maths, streaming policy, the shrinking zoneP9b

The split that matters most: /character versus /physics

Rapier is deterministic for a given binary but does not promise bit-identical results across architectures. Reconciliation works by rewinding to the server's state and re-running every input the client has not had acknowledged — so if that replay does not reproduce the server's arithmetic exactly, the correction never converges and the player rubber-bands forever.

So the line is drawn once, here:

Moved byAuthorityPredicted?
The local player/character, pure JSserver, replayed by the clientyes
AI, remote players/physics or /characterserverno — interpolated
Ragdolls, vehicles, debris, thrown props/physics (Rapier)serverno — interpolated

If you reach for /physics inside a function on the /character path, prediction quietly stops converging. Take a query result as an argument instead.

/character collides against axis-aligned boxes, not meshes. Arenas, cover, crates, walls, floors and platforms are boxes; terrain is not. When a title needs to walk on a mesh, the answer is a heightfield or a navmesh, not a swept triangle solver bolted on here.

Five traps

Never hold Component.array.x across a create(). Storage grows by doubling and reallocates the backing TypedArrays. Read .array each tick.

Never store the result of query(). It is a cached view, stable only until the next structural change. Iterate it and let it go.

physics.drain() is destructive and per-step. Exactly one consumer may call it, once, after each step(). A second caller gets an empty array and its triggers silently never fire.

Snapshot writing is O(players x visible entities). Interest management makes that cheap while players are spread out and does nothing once they are not: 24 dispersed players cost 1.4 ms/tick, but 60 players standing in one 60-metre circle cost 43 ms against a 33 ms budget. The fix is sharing one computed frame per interest cell, not a faster encoder. See bench/net.bench.mjs.

physics.takeSnapshot() is ~1.5KB per body (measured: 312KB for 201). It is for late-join keyframes and editor undo — never a per-tick operation.

Example

import { createWorld, createLoop, defineComponent } from '@leumas/game-kit';

const Transform = defineComponent('Transform', { x: 'f32', y: 'f32', z: 'f32' });
const Velocity = defineComponent('Velocity', { x: 'f32', y: 'f32', z: 'f32' });

const world = createWorld({ components: [Transform, Velocity], headless: true, seed: 1337 });

world.addSystem({
  id: 'integrate',
  phase: 'physics',
  reads: [Velocity],
  writes: [Transform],
  run: ({ world, dt }) => {
    const q = world.query([Transform, Velocity]);
    const t = Transform.array;
    const v = Velocity.array;
    for (let n = 0; n < q.length; n++) {
      const i = world.indexOf(q[n]);          // id -> index, once
      t.x[i] += v.x[i] * dt;
      t.y[i] += v.y[i] * dt;
      t.z[i] += v.z[i] * dt;
    }
  },
});

world.create({ Transform: { x: 0, y: 2, z: 0 }, Velocity: { x: 1, y: 0, z: 0 } });

const loop = createLoop({ hz: 30, step: (dt) => world.step(dt), onStarve: (ms) => console.warn('starved', ms) });
loop.start();

Verify

pnpm --filter @leumas/game-kit test    # 191 tests across 4 entry points
pnpm check:game                        # the renderer-free guarantee, plus its own self-test
node shared/packages/game-kit/bench/physics.bench.mjs   # tick p50/p95/p99 against the frame budget
node shared/packages/game-kit/bench/net.bench.mjs       # bytes/sec and write cost per client

The tests worth knowing about: the convergence test in net.test.js runs a two-client, 3,000-tick match over 150ms RTT with 5% loss and 5% reordering and asserts both clients converge — and asserts the impairments really happened, because a convergence test over a clean link proves nothing. REPLAY CONVERGES in character.test.js asserts that re-running inputs from a saved state reproduces the future exactly — that is the property prediction depends on, and it fails silently rather than loudly when broken. isomorphic.test.js asserts in plain Node, with no DOM shim, that no module beneath any entry point reaches for three, react or a browser global.

What it exports

301 exports: 121 components, 0 hooks, 180 helpers. Import from @leumas/game-kit.

AmmoARMOUR_COMPONENTSASSET_KINDSATTACKBASE_WEIGHTBOON_COMPONENTSBoonsBrainC2SCANCELSCHASSISCONDITION_OPSCORE_COMPONENTSCOYOTE_MSDEFINITION_KEYSDENIEDDOUBLE_JUMP_SPEEDDOWNDOWN_COMPONENTSDOWN_REFUSEDDownedENVIRONMENT_IDSFACESFALL_BOOSTFALLOFFFIELD_KINDSFIRINGGAME_KEYSGAME_SCHEMA_VERSIONGRAVITYGRAVITYHealthHitboxHORDE_COMPONENTSHUNTINTERACT_COMPONENTSInteractableItemJUMP_BUFFER_MSJUMP_SPEEDKICKLEDGE_HANGLEDGE_HANG_MSLEDGE_MANTLELEDGE_NONELOOT_COMPONENTSMANIFEST_VERSIONMANTLE_MAX_RISEMANTLE_MIN_RISEMANTLE_MSMANTLE_REACHMANTLE_RISE_FRACTIONMASK_WORDSMAX_COMPONENTSMAX_IMPACTMAX_PERKSMAX_REWIND_MSMAX_ROLLBACKMAX_TIMED_BOONSModuleMODULESMOVER_COMPONENTSMOVER_EASESMOVER_KINDSNetworkedNEUTRALNO_ENTITYORDERSORIENTED_SCHEMAOUTOUTCOMEOwnedPARTPERK_COMPONENTSPERK_REFUSEDPerksPERMISSIVEPERSIST_MODESPERSIST_VERSIONPHASEPHASESPlatePRESENTATION_COMPONENTSREADYREFUSEDREJECTRELOADINGRenderREWINDRiderRigidBodyROTATIONSRUN_MULTIPLIERS2CSCOPESSequenceSEQUENCE_COMPONENTSSOURCE_KINDSSPEEDSTAGGERSTATESSTEPStructureSTRUCTURE_COMPONENTSTeamTransformTurretUNREACHABLEUPVehicleVEHICLE_COMPONENTSVelocityVERBSVISIBILITYWalletWaveWAVE_COMPONENTSWIRE_VERSIONZONE_DONEZONE_HOLDZONE_SHRINK__resetComponentBitsacceptPayloadaimTurretapplyAreaapplyHitapplyInterpolatedapplyPenetrationareaCandidatesbakePrefabbeginReloadbindSeatblueprintsToPrefabsbotInputcanFirecaptureHitboxesclaimclaimsOfclampclamp01compileFramescompileGamecompileTablescomponentBudgetconfirmLedgecountBitscreateCharactercreateChunkGridcreateCollisionWorldcreateDownscreateFightercreateFlowFieldcreateHitLogcreateHordecreateInteractionscreateInterestGridcreateInterpolatorcreateLedgeStatecreateLevelPoolcreateLinkcreateLinkPaircreateLoopcreateLootcreateMatchFlowcreateMovercreateMoverSystemcreateOwnerRegistrycreatePersistencecreatePhasescreatePhysicsWorldcreatePredictorcreatePrefabPackcreateRewindBuffercreateRngcreateRngcreateRollbackcreateSnapshotCachecreateStreamingPolicycreateStructurescreateTurnOrdercreateWavescreateWeaponStatecreateWorldcreateZonecreateZonescrossdamageAtdampdecodeFramedecodeSnapshotdefineAssetdefineBoonsdefineComponentdefineGamedefineLeveldefineLootTabledefineMoverdefineNetSchemadefinePerksdefineSequence

Showing 200 of 301. See them all in the component index.

Source shared/packages/game-kit/README.md (no-git)markdownjson
Generated from the Leumas repository. Every page cites the file it came from.leumas.techllms.txt