Home
Reference

API reference

All functions and types are exported from koota-kit, except the React bindings, which are only available from koota-kit/react so the root entry never loads React. The module-specific import paths shown below can reduce coupling in larger codebases.

All functions and types are exported from koota-kit, except the React bindings, which are only available from koota-kit/react so the root entry never loads React. The module-specific import paths shown below can reduce coupling in larger codebases.

spatial

Import from koota-kit/spatial or the root:

function queryPointClearance(
  provider: SpatialProvider,
  oracle: SpatialNearestSurfaceOracle,
  query: SpatialClearanceQuery,
): SpatialClearanceResult;

queryPointClearance decides one explicit predicate: the query point is at least minimumDistance from every selected open surface. The default selection is each part's full topology; callers must explicitly request visible topology. A provider record carries stable source, instance, part, and provider-local registration identity; a common Euclidean frame and unit; independent geometry, topology, pose, and transform revisions; and certified enclosing AABBs for both topology selections.

The core performs deterministic AABB broadphase only. It invokes the supplied nearest-surface oracle for overlapping candidates and validates that its witness is in the selected certified bound, in the same frame/unit, and was computed from the exact consumed identity/topology/revision tuple. Results include those checked tuples, ordered witnesses, exclusions, one of satisfied, violated, unknown, unsupported, or stale, and an exactness label. An AABB rejection can conservatively satisfy the threshold; an oracle witness or a stamped bounded no-hit is exact. Satisfaction that combines AABB and oracle evidence is conservative. A stale expected/provider tuple wins before the result can be trusted; otherwise an exact violating witness wins over an unavailable or foreign-frame peer. Sparse provider snapshots and capability lists fail closed. Validated query thresholds are captured before any provider or oracle callback and cannot change during evaluation.

There is no geometry kernel, BVH, renderer, model, physics, Koota, or React dependency in this entry. It does not prove containment or solid nonpenetration: an open-surface nearest query has no watertightness/thickness semantics. A future solid query needs an injected containment-capable oracle.

world

createSimWorld(seeds)

function createSimWorld(seeds: RngSeeds): WorldHandle;

Creates a Koota World, two seeded RNG streams, immutable copied seeds, a zeroed clock, and an empty scratch map. Seeds must be strings or finite numbers. Validation happens before the Koota world is allocated.

destroySimWorld(handle)

function destroySimWorld(handle: WorldHandle): void;

Clears scratch state and destroys the Koota world if it is still active. The operation is idempotent.

advanceClock(handle, dt)

function advanceClock(handle: WorldHandle, dt: number): void;

Advances tickIndex by one and adds dt to simSeconds. dt must be finite and non-negative. A zero-duration tick is allowed. Overflow and corrupted clock values raise RangeError without changing the clock.

snapshotWorld(handle) / restoreWorldHeader(handle, snapshot)

function snapshotWorld(handle: WorldHandle): WorldSnapshot;
function restoreWorldHeader(handle: WorldHandle, snapshot: WorldSnapshot): void;

Snapshots and restores both RNG streams plus the simulation clock. The plain object can be round-tripped through JSON. It does not include WorldHandle.seeds: persist the immutable seeds separately and use them when creating a fresh handle for loading. Entity and trait data are also outside this package's persistence boundary. Restore validates the complete replacement before mutating the handle.

Koota exports

createActions, relation, and trait are re-exported so simulation code can keep its Koota imports at one boundary. Entity and World are exported as types.

rng

type RngSeeds = {
  readonly gen: string | number;
  readonly events: string | number;
};

function createRng(seeds: RngSeeds): RngLayers;
function nextU32(stream: RngStream): number;
function nextFloat(stream: RngStream): number;
function nextInt(stream: RngStream, minInclusive: number, maxExclusive: number): number;
function chance(stream: RngStream, probability: number): boolean;
function pick<T>(stream: RngStream, items: readonly T[]): T;
function shuffle<T>(stream: RngStream, items: readonly T[]): T[];
function snapshotStream(stream: RngStream): RngStreamSnapshot;
function restoreStream(snapshot: RngStreamSnapshot): RngStream;
function snapshotLayers(layers: RngLayers): RngLayersSnapshot;
function restoreLayers(snapshot: RngLayersSnapshot): RngLayers;
  • nextU32 returns an unsigned 32-bit integer.
  • nextFloat returns a value in [0, 1).
  • nextInt uses half-open bounds and exactly one float draw after validation.
  • chance consumes one draw for every valid probability, including 0 and 1, keeping later draw positions predictable.
  • pick returns one element uniformly with exactly one nextInt draw and rejects an empty array without drawing.
  • shuffle returns a Fisher–Yates shuffled copy (the input is never mutated) and consumes exactly length - 1 draws, so the draw count depends only on the length, never on the contents.
  • Restore functions reject malformed ARC4 state and clone accepted state so a snapshot can seed multiple independent replays.

Named substreams

type Substreams = {
  readonly seed: string | number;
  readonly streams: ReadonlyMap<string, RngStream>;
};
type SubstreamsSnapshot = {
  seed: string | number;
  streams: Record<string, RngStreamSnapshot>;
};

function substream(seed: string | number, name: string): RngStream;
function createSubstreams(seed: string | number): Substreams;
function getSubstream(substreams: Substreams, name: string): RngStream;
function snapshotSubstreams(substreams: Substreams): SubstreamsSnapshot;
function restoreSubstreams(snapshot: SubstreamsSnapshot): Substreams;
  • substream(seed, name) returns a fresh stream seeded by deriveSeed(seed, name). Its sequence depends only on seed and name, so adding, removing, reordering, or consuming another substream never shifts it.
  • name must be a non-empty string. Invalid input throws before anything is allocated.
  • createSubstreams holds one lazily created stream per name, so generators that share a set keep drawing where they left off. getSubstream returns the same stream instance for the same name.
  • snapshotSubstreams captures the seed plus every stream created so far, with names in sorted order. restoreSubstreams validates the whole snapshot before building anything and reproduces every later draw byte-exact. Streams that were never created restore lazily from the seed.

seed

type SeedPart = string | number;
type EntropySource = {
  getRandomValues(array: Uint8Array<ArrayBuffer>): Uint8Array<ArrayBuffer>;
}; // `globalThis.crypto` satisfies it, with or without DOM typings

const MASTER_SEED_BYTES = 16;

function deriveSeed(parent: SeedPart, ...parts: SeedPart[]): string;
function createMasterSeed(entropy?: EntropySource): string;
function isMasterSeed(value: unknown): value is string;
  • deriveSeed hashes the parent seed and every part with SHA-256 and returns the first 128 bits as 32 lowercase hex characters. The result is a valid seed for createRng, createSimWorld, substream, and further deriveSeed calls.
  • Every value is length-prefixed before hashing, so ("a", "bc") and ("ab", "c") derive different seeds. Order matters.
  • Numbers canonicalize to their string form, matching createRng, so deriveSeed(7, "x") === deriveSeed("7", "x"). Non-finite numbers and non-string, non-number values throw.
  • The output is stable across platforms and package versions. A change to the derivation would be a breaking change.
  • createMasterSeed fills 16 bytes from entropy (default globalThis.crypto) and returns them as 32 lowercase hex characters. It throws when no entropy source exists, when the source returns something other than the requested 16-byte array, or when it returns all zero bytes, which signals an unfilled buffer rather than real entropy.
  • isMasterSeed accepts exactly 32 lowercase hex characters. Use it to validate a persisted seed before deriving from it.

traits

function defineTrait<S extends Schema>(schema: S & SafeSchema<S>): Trait<Norm<S>>;

Pass primitive fields directly. Wrap each object- or array-valued field in a factory, or use a whole-trait factory:

const Health = defineTrait({ value: 100 });
const Path = defineTrait({ points: () => [] as Array<{ x: number; y: number }> });
const Pose = defineTrait(() => ({ position: { x: 0, y: 0 } }));

Bare object and array fields are rejected by SafeSchema and by the runtime guard.

schedule

Import from koota-kit/schedule or the root.

type System = (handle: WorldHandle, dt: number) => void;

type ScheduleOptions = {
  step: number;     // seconds per step; finite, > 0
  maxSteps: number; // safe integer >= 1: most steps one tick may run
  key?: string;     // scratch key for the accumulator; unique by default
};

type Schedule = {
  readonly step: number;
  readonly maxSteps: number;
  readonly key: string;
  tick(handle: WorldHandle, frameDt: number): number;
  remainder(handle: WorldHandle): number;
};

function createSchedule(systems: readonly System[], options: ScheduleOptions): Readonly<Schedule>;

createSchedule copies systems and validates step, maxSteps, key and every system before returning; bad configuration throws TypeError or RangeError naming createSchedule.

tick(handle, frameDt) adds frameDt to an accumulator stored in handle.scratch under key, then, while at least one whole step is owed and fewer than maxSteps have run, calls advanceClock(handle, step) and then every system in order with dt === step. It returns the number of steps run. Systems read dt, never the frame's dt, so the same elapsed time gives the same run at any frame rate, provided no single tick owes more than maxSteps (the excess is dropped, so one long frame and many short ones then differ).

  • A frameDt that is negative, NaN or infinite counts as 0. 0 is a valid pause: no step runs and the clock does not move, even if a failed tick left whole steps owed; those wait for the next frame that moves time.
  • When maxSteps is reached with whole steps still owed, those steps are dropped, not carried. The sub-step fraction is kept, so phase is preserved.
  • Owed time saturates at the largest finite double instead of overflowing.
  • A billionth of a step of tolerance absorbs float drift, so 432 frames of 1/144 s run the same 180 steps as 180 frames of 1/60 s.
  • The accumulator lives in scratch, so it dies with the world and two handles never share one. A key already holding something other than a schedule accumulator throws TypeError before the clock moves. Two schedules given the same explicit key share one accumulator, but an explicit key may not equal another schedule's generated key, and a generated key skips any explicit one.
  • If a system throws, the step it was in is already spent (the clock moved) and the steps not yet started stay owed, so a retry does not replay spent time.

remainder(handle) returns the seconds currently owed, in [0, step) between ticks (0 on a handle that has not ticked). remainder / step is the interpolation alpha for a renderer drawing between sim states.

eventLog

type EventLog<T> = {
  readonly key: string;
  push(handle: WorldHandle, event: T): void;
  drain(handle: WorldHandle): T[];
  peek(handle: WorldHandle): readonly T[];
  clear(handle: WorldHandle): void;
  size(handle: WorldHandle): number;
};

function defineEventLog<T>(key: string): EventLog<T>;

Logs are created once at module scope but store their arrays in each handle's scratch map. drain truncates the live array in place so an existing internal reference is not orphaned. Empty reads do not create a scratch entry. peek returns a detached array; size does not allocate.

coordination

Import from koota-kit/coordination or the root. This React-free entry binds to an existing caller-owned Koota World; it never creates, resets, or destroys that World or its entities.

type CompletedRevisions = Readonly<{
  geometry: number;
  pose: number;
  transform: number;
}>;

type CompletedMetadata = Readonly<{ revisions: CompletedRevisions }>;
type CoordinationDescriptor = Readonly<{
  id: string;
  capabilities: readonly string[];
}>;
type CoordinationUpdate = Readonly<{
  token: RegistrationToken;
  completed: CompletedMetadata;
}>;
type CoordinationEntry = Readonly<{
  id: string;
  capabilities: readonly string[];
  completed: CompletedMetadata | undefined;
}>;
type CoordinationSnapshot = Readonly<{
  revision: number;
  entries: readonly CoordinationEntry[];
}>;

function bindCoordination(world: World): Coordination;

id is a caller-authored, non-empty stable string. It is not a packed Koota entity ID. Capability labels are copied, sorted, and must be unique non-empty strings; change a capability set by unregistering and registering again. completed.revisions.geometry, .pose, and .transform are all required non-negative safe integers. They may advance independently but no dimension may decrease. A new completed value with all three values equal is a no-op.

type Coordination = Readonly<{
  readonly world: World;
  register(entity: Entity, descriptor: CoordinationDescriptor): RegistrationToken;
  publish(token: RegistrationToken, completed: CompletedMetadata): CoordinationSnapshot;
  publishBatch(updates: readonly CoordinationUpdate[]): CoordinationSnapshot;
  unregister(token: RegistrationToken): CoordinationSnapshot;
  prune(): CoordinationSnapshot;
  getRegistration(id: string): RegistrationToken | undefined;
  getEntity(id: string): Entity | undefined;
  getSnapshot(): CoordinationSnapshot;
  subscribe(listener: (snapshot: CoordinationSnapshot) => void): () => void;
  dispose(): void;
}>;

register accepts only a live entity in the supplied World; cross-World, destroyed, duplicated, and externally conflicting entities are rejected before the coordinator mutates its projection. The opaque token is scoped to one binding and to the entity generation. publish and unregister reject a stale token. If an entity is destroyed or the owned registration trait is removed externally, call prune() to publish its removal before reusing the stable ID. If Koota rejects the binding marker during construction or rejects an add callback during register, the binding removes only its just-added marker or trait before rethrowing, so an existing peer binding remains intact and the caller may retry registration. getEntity and getRegistration return undefined for a stale registration.

Use one Koota runtime and module format per process. Koota 0.6.6 replaces numeric entity dispatch when its opposite ESM/CommonJS format loads. Binding rejects a foreign peer World before adding its marker, and attests the existing marker holder to detect contamination that predates the first kit import. Later live reads, mutations, and cleanup throw a coordination: dispatch-realm error if that attestation fails; they preserve the last committed snapshot and subscribers. getSnapshot() remains readable. A subscriber that changes dispatch after publication triggers an error after all listeners receive the newly committed snapshot; that publication stays committed. An opposite-format koota-kit/spatial import is safe because that entry does not load Koota. This guard detects the known peer-format conflict; it does not repair global methods or make mixed-format entity operations interoperable.

publishBatch validates every token and metadata value before it changes any owned trait or snapshot. Its completed projection changes only after every raw Koota trait write succeeds; a Koota callback error can leave partial raw mirror writes, which are outside that projection. A completed snapshot is immutable, detached from the caller input, and sorted by stable ID. Producers that need a cross-producer atomic cohort must publish through the same binding; disjoint bindings are independent cohorts. revision is a binding-local committed publication generation, not a geometry, pose, transform, source-hash, or provider freshness claim. It deliberately does not serialize entities or join the RNG/clock WorldSnapshot boundary.

Subscribers each receive the same committed snapshot. A subscribe or unsubscribe during delivery affects only a later publication. Registry mutation during delivery or an owned Koota trait callback throws. Listener failures are reported after every listener has been offered the committed snapshot; they do not roll back it. When a listener throws during register, use getRegistration(id) to recover the successfully committed token.

dispose is idempotent. It removes only this binding's registration traits and World-held ownership marker, keeps other bindings' markers, and never destroys or resets the borrowed World, its entities, caller traits, providers, or buffers. Several bindings may share a World only for disjoint entities.

CoordinationBinding and CoordinationRegistration are the exported Koota trait identities for this module format. The binding trait holds only opaque per-binding ownership markers on the borrowed World; it is removed when the last binding disposes. Direct trait mutation is outside the immutable committed- snapshot contract; use the binding methods for publication. The module has no renderer, React, producer, geometry, spatial-index, or AI dependency. Exact adapters must own their own immutable buffers or read leases and report their own source, topology, frame, unit, and freshness contracts.

react

Import from koota-kit/react. Requires react (>=18), an optional peer dependency; no other entry point imports it.

type SimWorldProviderProps = {
  readonly handle: WorldHandle;
  readonly children?: ReactNode;
};

const SimWorldProvider: FunctionComponent<SimWorldProviderProps>;
function useSimWorld(): WorldHandle;

SimWorldProvider supplies handle to the tree below it and renders koota/react's WorldProvider for handle.world, so useWorld, useTrait, useQuery and useActions work under it without a second provider. useSimWorld returns the provided handle, including its rng, seeds, clock and scratch, and throws an Error when no SimWorldProvider is above it. Passing a different handle re-provides every consumer. The provider does not create or destroy the handle: pair createSimWorld with destroySimWorld yourself.