koota-kit
v0.9.11
Published
Deterministic Koota simulation conventions: world lifecycle, dual RNG streams, safe object traits, and world-scoped event logs.
Maintainers
Readme
koota-kit

Small, production-derived conventions for deterministic simulations built with Koota. koota-kit gives a simulation one lifecycle boundary, two independent seeded random streams, safe object-valued traits, and world-scoped event logs.
It is deliberately not a game engine. It is the narrow layer that prevents recurring classes of simulation bug without hiding Koota's queries, entities, relations, or actions.
Full documentation: jonbogaty.com/koota-kit
Why use it?
| Problem | koota-kit convention |
| --- | --- |
| World generation changes after unrelated gameplay draws | Separate gen and events RNG streams |
| A loaded save no longer reproduces the next random result | Byte-exact, JSON-safe RNG snapshots |
| Object state leaks between entities | defineTrait requires per-entity factories |
| A HUD or test consumes an event before its real system sees it | Single-consumer drain, non-consuming peek |
| Replaced worlds retain caches or process-global Koota IDs | createSimWorld / idempotent destroySimWorld lifecycle |
| Two producers publish half-updated model facts | koota-kit/coordination commits an immutable completed metadata projection |
Install
pnpm add koota-kit kootaRequirements:
- Node.js 22, 24 and 26 (CI covers each on Linux and Node 24 on Windows).
The package declares
>=22; support follows maintained Node lines rather than requiring one exact patch release. - Koota
^0.6.6, installed by the application as a peer dependency - React
>=18, only if you importkoota-kit/react; it is an optional peer dependency, and no other entry point loads it
The package ships native ESM and CommonJS entry points with format-correct TypeScript declarations.
Quick start
import {
advanceClock,
createSimWorld,
defineEventLog,
defineTrait,
destroySimWorld,
nextInt,
snapshotWorld,
} from "koota-kit";
const Position = defineTrait({ x: 0, y: 0 });
const Inventory = defineTrait({ items: () => [] as string[] });
const moved = defineEventLog<{ entity: number; distance: number }>("movement:completed");
const sim = createSimWorld({ gen: "map-42", events: "run-1" });
try {
const scout = sim.world.spawn(Position({ x: 4, y: 9 }), Inventory());
advanceClock(sim, 1 / 60);
const distance = nextInt(sim.rng.events, 1, 5);
const position = scout.get(Position);
if (position) scout.set(Position, { ...position, x: position.x + distance });
moved.push(sim, { entity: Number(scout), distance });
// The owning system consumes each event once.
for (const event of moved.drain(sim)) {
console.log(event);
}
// Save this beside your own serialized entity/trait state.
const header = snapshotWorld(sim);
const serializedHeader = JSON.stringify(header);
console.log(serializedHeader);
} finally {
destroySimWorld(sim);
}Runnable ESM and CommonJS versions live in examples/.
Core concepts
One handle owns one simulation
createSimWorld creates a Koota world, immutable seed values, independent RNG
streams, a fixed-step clock, and a scratch map. destroySimWorld clears the
scratch map and releases Koota's world ID; it is safe to call more than once.
The scratch map is for derived, non-serializable caches and short-lived
coordination state. Namespace keys by system, for example pathfinding:grid
or combat:hits.
Coordinate existing model entities without another World
koota-kit/coordination binds to a caller-owned raw Koota World; it does
not create, reset, or destroy one. Register existing entities with stable
caller-authored IDs and capability labels, then publish completed producer
revisions as a batch. The snapshot is immutable, ordered by ID, and changes
only after the complete batch validates and its raw Koota trait writes succeed.
Producers needing one atomic cohort publish through the same binding; disjoint
bindings are independent cohorts.
import { bindCoordination } from "koota-kit/coordination";
const coordination = bindCoordination(sim.world);
const ava = sim.world.spawn(Position({ x: 4, y: 9 }));
const token = coordination.register(ava, {
id: "figure:ava",
capabilities: ["presence", "geometry"],
});
coordination.publish(token, {
revisions: { geometry: 12, pose: 8, transform: 31 },
});
const completed = coordination.getSnapshot();
coordination.dispose(); // removes only koota-kit's coordination traitsThe three producer revisions are independent values; a coordination snapshot
revision only identifies a committed observer view. This entry does not copy
or freeze provider buffers, choose a geometry backend, evaluate a pose, or
answer spatial queries. Those facts belong to later producer/query adapters.
Call prune() after an entity is removed externally before reusing its stable
ID. Listener errors occur after a committed publication; retrieve a token with
getRegistration(id) if registration notification threw before register
could return it.
Use one Koota runtime and module format per process. Koota 0.6.6 replaces numeric
entity dispatch when the opposite ESM/CommonJS peer format loads. Coordination
then rejects live reads, writes, and cleanup with a dispatch-realm error;
getSnapshot() still returns its last committed view. Raw callback writes may
already have happened, but failed cleanup never reports successful disposal.
The independent koota-kit/spatial entry can be imported in either format.
The caller composition proof documents the optional registry consumer, copied figure inputs, immutable geometry captures and the limits of open-surface integration evidence.
Randomness has two jobs
rng.gendefines stable world content: terrain, layouts, initial spawns.rng.eventsdefines play history: combat rolls, weather, runtime events.
Never key per-entity randomness on packed Koota entity IDs or consume a shared stream in query iteration order. Entity IDs contain process-global world and generation bits. Derive a local stream from a stable domain key instead:
import { nextU32, substream } from "koota-kit/rng";
import { deriveSeed } from "koota-kit/seed";
const tileSeed = deriveSeed(sim.seeds.gen, "tile", 12, 8);
const terrainVariant = nextU32(substream(tileSeed, "terrain"));deriveSeed hashes length-prefixed parts into a 128-bit hex seed, so seed
chains such as master seed → run → level never collide by accident.
substream(seed, name) gives each generator its own stream; adding a new
named substream never shifts an existing one. createMasterSeed() makes a
128-bit root seed from crypto.getRandomValues, and accepts an injected
entropy source for tests.
Object-valued traits need factories
Koota's structure-of-arrays layout can alias a bare object literal across entities. koota-kit rejects that form at compile time and runtime:
defineTrait({ storage: () => ({ logs: 0 }) }); // correct: one object per entity
defineTrait({ storage: { logs: 0 } }); // TypeError: shared object footgunDeclare traits once in a leaf module and export each trait from one location. Koota identifies traits by reference, not by a string name.
A fixed-step schedule removes the frame rate from the sim
createSchedule(systems, { step, maxSteps }) returns a tick(handle, frameDt)
for your frame loop. The frame's dt fills an accumulator kept in handle.scratch
(so it dies with the run); each whole step advances the clock, then calls every
system in order with dt === step. At most maxSteps run per tick and any
excess is dropped, so one huge dt after a backgrounded tab cannot cause a
catch-up storm. tick(handle, 0) is a valid pause and returns the steps run.
import { createSchedule } from "koota-kit";
const schedule = createSchedule([stove, mixer, serving], { step: 1 / 60, maxSteps: 6 });
// once per rendered frame:
const steps = schedule.tick(sim, frameDt);Sixty frames of 1/60 s and thirty of 1/30 s run the same sixty steps.
schedule.remainder(sim) / schedule.step is the interpolation alpha.
Event logs have one consuming owner
push appends, drain returns every pending event and empties the log, and
peek returns a copy without consuming. A devtool, HUD, or test should peek;
the system responsible for acting on the event should drain.
Persistence boundary
snapshotWorld stores the clock and both RNG states. It intentionally does not
serialize immutable seeds, entities, or traits: persist sim.seeds beside the
header so a fresh handle can retain stable derived randomness, and keep entity
data in application-specific schemas and migrations. Stage a fresh handle when
loading so old scratch caches and event logs cannot leak into the restored run.
restoreWorldHeader validates a parsed save before changing that staged handle
and restores atomically.
Persist a version beside the header and your ECS state so the application can migrate its own schema.
React
koota/react hooks reach only the Koota World. koota-kit/react adds a
provider for the whole WorldHandle, so components can also reach its RNG
streams, seeds, clock and scratch:
import { useQuery } from "koota/react";
import { createSimWorld, defineTrait, nextInt } from "koota-kit";
import { SimWorldProvider, useSimWorld } from "koota-kit/react";
const Position = defineTrait({ x: 0, y: 0 });
const handle = createSimWorld({ gen: "world-7", events: "run-7" });
function Scouts() {
const { rng } = useSimWorld(); // the handle: rng, seeds, clock, scratch
const scouts = useQuery(Position); // koota/react hooks share the same provider
return (
<button type="button" onClick={() => nextInt(rng.events, 1, 7)}>
Roll for {scouts.length} scouts
</button>
);
}
root.render(
<SimWorldProvider handle={handle}>
<Scouts />
</SimWorldProvider>,
);SimWorldProvider renders Koota's own WorldProvider for handle.world, so
useTrait, useQuery and useActions need no second provider. useSimWorld()
throws outside a provider. Passing a different handle re-provides every
consumer; the provider does not create or destroy the handle, so pair
createSimWorld with destroySimWorld yourself.
API and architecture
The root package exports the full API except the React bindings, which live
only at koota-kit/react so the root never loads React. Focused entry points
are also available:
| Entry point | Purpose |
| --- | --- |
| koota-kit/world | World lifecycle, clock, Koota facade, world snapshots |
| koota-kit/rng | Seeded streams, named substreams, bounded draws, snapshots |
| koota-kit/seed | Seed derivation and 128-bit master seeds |
| koota-kit/traits | Safe trait declaration |
| koota-kit/schedule | Fixed-step system schedule with a step cap |
| koota-kit/eventLog | World-scoped publish/drain logs |
| koota-kit/spatial | Renderer-neutral point-to-open-surface clearance queries |
| koota-kit/react | SimWorldProvider and useSimWorld (optional React peer) |
See the API reference for signatures and validation behavior, and architecture notes for module boundaries and invariants.
For task-oriented usage, read the Sourcey guides on deterministic randomness, save headers, and event ownership.
Errors and edge cases
- Seeds must be strings or finite numbers. Substream names must be non-empty strings.
createMasterSeedthrows when no entropy source exists or the source leaves the buffer all zero.advanceClockaccepts finite, non-negativedt;0is a valid paused tick.createScheduleneeds a finitestep > 0and a safe-integermaxSteps >= 1; a negative,NaNor infiniteframeDtcounts as0.queryPointClearanceaccepts only finite coordinates and a shared Euclidean frame/unit. It proves distance to declared open surfaces, never containment or solid nonpenetration.nextIntrequires safe-integer bounds withmaxExclusive > minInclusiveand a range no larger than2**32.chanceaccepts only finite probabilities in[0, 1].- Restores reject malformed RNG or clock state before mutating the handle.
- Event-log keys must be non-empty and cannot reuse a scratch key holding a non-array cache.
Invalid caller input raises TypeError or RangeError with the API name in the
message. No validation failure consumes RNG state or partially restores a
world header.
Development
mise install # or: corepack enable
pnpm install --frozen-lockfile
pnpm verifyNode and pnpm versions live in mise.toml; CI verifies against the full
supported Node range (22, 24, 26).
pnpm verify runs formatting/lint checks, strict TypeScript, the full test
suite with 100% coverage thresholds, dual-format builds, both runnable
examples, publint, Are The Types Wrong, and package-content/runtime checks.
Useful individual commands:
pnpm test # fast behavior suite
pnpm test:watch # local watch mode
pnpm coverage # tests plus enforced coverage thresholds
pnpm build # dist/esm and dist/cjs
pnpm package:check # exports, declarations, packed files, ESM/CJS parity
pnpm audit --prod # registry-backed production dependency auditReleases and support
Release Please uses Conventional Commits to select versions and GitHub's
merged-pull-request release notes to maintain CHANGELOG.md. A release tag
triggers a clean verification and npm publication with provenance. The latest
0.x release receives fixes; pre-1.0 APIs may evolve, with breaking changes
called out in the changelog.
See CONTRIBUTING.md before opening a pull request. Report security issues privately according to SECURITY.md.
License
MIT © Jon Bogaty.
