koota-kit 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.0](https://github.com/jbcom/koota-kit/compare/v0.5.0...v0.6.0) (2026-10-10)
4
+
5
+
6
+ ### Features
7
+
8
+ * **schedule:** add createSchedule, a fixed-step system schedule ([bb69bd6](https://github.com/jbcom/koota-kit/commit/bb69bd638e7a3dc6315ede827e4c58820facea3a))
9
+ * **schedule:** add createSchedule, a fixed-step system schedule ([21f6ffb](https://github.com/jbcom/koota-kit/commit/21f6ffbb2a6f4fae819f88197d02e30f50cc98ea))
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * **schedule:** pause means no steps, and a refused step is never debited ([e2fbfad](https://github.com/jbcom/koota-kit/commit/e2fbfadb363764bc8c43232e19862223ef250ee6))
15
+
16
+ ## [0.5.0](https://github.com/jbcom/koota-kit/compare/v0.4.0...v0.5.0) (2026-10-10)
17
+
18
+
19
+ ### Features
20
+
21
+ * **react:** add koota-kit/react with SimWorldProvider and useSimWorld ([3a9aeee](https://github.com/jbcom/koota-kit/commit/3a9aeee17d67af90bb8be925fd123bfba32f2968))
22
+ * **react:** add koota-kit/react with SimWorldProvider and useSimWorld ([74a5f1c](https://github.com/jbcom/koota-kit/commit/74a5f1c683246797dfa60010375a3a4f6333d37c))
23
+
3
24
  ## [0.4.0](https://github.com/jbcom/koota-kit/compare/v0.3.1...v0.4.0) (2026-10-08)
4
25
 
5
26
 
package/README.md CHANGED
@@ -38,6 +38,8 @@ Requirements:
38
38
  The package declares `>=22`; support follows maintained Node lines rather
39
39
  than requiring one exact patch release.
40
40
  - Koota `^0.6.6`, installed by the application as a peer dependency
41
+ - React `>=18`, only if you import `koota-kit/react`; it is an optional peer
42
+ dependency, and no other entry point loads it
41
43
 
42
44
  The package ships native ESM and CommonJS entry points with format-correct
43
45
  TypeScript declarations.
@@ -135,6 +137,26 @@ defineTrait({ storage: { logs: 0 } }); // TypeError: shared object footg
135
137
  Declare traits once in a leaf module and export each trait from one location.
136
138
  Koota identifies traits by reference, not by a string name.
137
139
 
140
+ ### A fixed-step schedule removes the frame rate from the sim
141
+
142
+ `createSchedule(systems, { step, maxSteps })` returns a `tick(handle, frameDt)`
143
+ for your frame loop. The frame's dt fills an accumulator kept in `handle.scratch`
144
+ (so it dies with the run); each whole `step` advances the clock, then calls every
145
+ system in order with `dt === step`. At most `maxSteps` run per tick and any
146
+ excess is dropped, so one huge dt after a backgrounded tab cannot cause a
147
+ catch-up storm. `tick(handle, 0)` is a valid pause and returns the steps run.
148
+
149
+ ```ts
150
+ import { createSchedule } from "koota-kit";
151
+
152
+ const schedule = createSchedule([stove, mixer, serving], { step: 1 / 60, maxSteps: 6 });
153
+ // once per rendered frame:
154
+ const steps = schedule.tick(sim, frameDt);
155
+ ```
156
+
157
+ Sixty frames of 1/60 s and thirty of 1/30 s run the same sixty steps.
158
+ `schedule.remainder(sim) / schedule.step` is the interpolation alpha.
159
+
138
160
  ### Event logs have one consuming owner
139
161
 
140
162
  `push` appends, `drain` returns every pending event and empties the log, and
@@ -154,9 +176,48 @@ and restores atomically.
154
176
  Persist a version beside the header and your ECS state so the application can
155
177
  migrate its own schema.
156
178
 
179
+ ## React
180
+
181
+ `koota/react` hooks reach only the Koota `World`. `koota-kit/react` adds a
182
+ provider for the whole `WorldHandle`, so components can also reach its RNG
183
+ streams, seeds, clock and scratch:
184
+
185
+ ```tsx
186
+ import { useQuery } from "koota/react";
187
+ import { createSimWorld, defineTrait, nextInt } from "koota-kit";
188
+ import { SimWorldProvider, useSimWorld } from "koota-kit/react";
189
+
190
+ const Position = defineTrait({ x: 0, y: 0 });
191
+ const handle = createSimWorld({ gen: "world-7", events: "run-7" });
192
+
193
+ function Scouts() {
194
+ const { rng } = useSimWorld(); // the handle: rng, seeds, clock, scratch
195
+ const scouts = useQuery(Position); // koota/react hooks share the same provider
196
+ return (
197
+ <button type="button" onClick={() => nextInt(rng.events, 1, 7)}>
198
+ Roll for {scouts.length} scouts
199
+ </button>
200
+ );
201
+ }
202
+
203
+ root.render(
204
+ <SimWorldProvider handle={handle}>
205
+ <Scouts />
206
+ </SimWorldProvider>,
207
+ );
208
+ ```
209
+
210
+ `SimWorldProvider` renders Koota's own `WorldProvider` for `handle.world`, so
211
+ `useTrait`, `useQuery` and `useActions` need no second provider. `useSimWorld()`
212
+ throws outside a provider. Passing a different `handle` re-provides every
213
+ consumer; the provider does not create or destroy the handle, so pair
214
+ `createSimWorld` with `destroySimWorld` yourself.
215
+
157
216
  ## API and architecture
158
217
 
159
- The root package exports the full API. Focused entry points are also available:
218
+ The root package exports the full API except the React bindings, which live
219
+ only at `koota-kit/react` so the root never loads React. Focused entry points
220
+ are also available:
160
221
 
161
222
  | Entry point | Purpose |
162
223
  | --- | --- |
@@ -164,7 +225,9 @@ The root package exports the full API. Focused entry points are also available:
164
225
  | `koota-kit/rng` | Seeded streams, named substreams, bounded draws, snapshots |
165
226
  | `koota-kit/seed` | Seed derivation and 128-bit master seeds |
166
227
  | `koota-kit/traits` | Safe trait declaration |
228
+ | `koota-kit/schedule` | Fixed-step system schedule with a step cap |
167
229
  | `koota-kit/eventLog` | World-scoped publish/drain logs |
230
+ | `koota-kit/react` | `SimWorldProvider` and `useSimWorld` (optional React peer) |
168
231
 
169
232
  See the [API reference](./docs/API.md) for signatures and validation behavior,
170
233
  and [architecture notes](./docs/ARCHITECTURE.md) for module boundaries and
@@ -182,6 +245,8 @@ For task-oriented usage, read the Sourcey guides on
182
245
  - `createMasterSeed` throws when no entropy source exists or the source leaves
183
246
  the buffer all zero.
184
247
  - `advanceClock` accepts finite, non-negative `dt`; `0` is a valid paused tick.
248
+ - `createSchedule` needs a finite `step > 0` and a safe-integer `maxSteps >= 1`;
249
+ a negative, `NaN` or infinite `frameDt` counts as `0`.
185
250
  - `nextInt` requires safe-integer bounds with `maxExclusive > minInclusive` and
186
251
  a range no larger than `2**32`.
187
252
  - `chance` accepts only finite probabilities in `[0, 1]`.
@@ -1,8 +1,9 @@
1
1
  "use strict";
2
2
  // koota-kit — thin koota ECS conventions layer.
3
3
  //
4
- // Five modules, also importable as subpaths:
4
+ // Six modules, also importable as subpaths:
5
5
  // ./world — WorldHandle lifecycle (world + dual-stream rng + clock + scratch)
6
+ // ./schedule — fixed-step system schedule (accumulator in scratch, step cap)
6
7
  // ./rng — dual-layer deterministic seedrandom PRNG and named substreams
7
8
  // with byte-exact snapshot/restore
8
9
  // ./seed — length-prefixed SHA-256 seed derivation and 128-bit master
@@ -12,7 +13,7 @@
12
13
  // ./eventLog — scratch-backed publish/drain event logs with a peek seam for
13
14
  // observers (harnesses, tests, HUDs)
14
15
  Object.defineProperty(exports, "__esModule", { value: true });
15
- exports.defineTrait = exports.defineEventLog = exports.MASTER_SEED_BYTES = exports.isMasterSeed = exports.deriveSeed = exports.createMasterSeed = exports.substream = exports.snapshotSubstreams = exports.snapshotStream = exports.snapshotLayers = exports.shuffle = exports.restoreSubstreams = exports.restoreStream = exports.restoreLayers = exports.pick = exports.nextU32 = exports.nextInt = exports.nextFloat = exports.getSubstream = exports.createSubstreams = exports.createRng = exports.chance = exports.trait = exports.snapshotWorld = exports.restoreWorldHeader = exports.relation = exports.destroySimWorld = exports.createSimWorld = exports.createActions = exports.advanceClock = void 0;
16
+ exports.defineTrait = exports.defineEventLog = exports.createSchedule = exports.MASTER_SEED_BYTES = exports.isMasterSeed = exports.deriveSeed = exports.createMasterSeed = exports.substream = exports.snapshotSubstreams = exports.snapshotStream = exports.snapshotLayers = exports.shuffle = exports.restoreSubstreams = exports.restoreStream = exports.restoreLayers = exports.pick = exports.nextU32 = exports.nextInt = exports.nextFloat = exports.getSubstream = exports.createSubstreams = exports.createRng = exports.chance = exports.trait = exports.snapshotWorld = exports.restoreWorldHeader = exports.relation = exports.destroySimWorld = exports.createSimWorld = exports.createActions = exports.advanceClock = void 0;
16
17
  var world_js_1 = require("./world.cjs");
17
18
  Object.defineProperty(exports, "advanceClock", { enumerable: true, get: function () { return world_js_1.advanceClock; } });
18
19
  Object.defineProperty(exports, "createActions", { enumerable: true, get: function () { return world_js_1.createActions; } });
@@ -44,6 +45,8 @@ Object.defineProperty(exports, "createMasterSeed", { enumerable: true, get: func
44
45
  Object.defineProperty(exports, "deriveSeed", { enumerable: true, get: function () { return seed_js_1.deriveSeed; } });
45
46
  Object.defineProperty(exports, "isMasterSeed", { enumerable: true, get: function () { return seed_js_1.isMasterSeed; } });
46
47
  Object.defineProperty(exports, "MASTER_SEED_BYTES", { enumerable: true, get: function () { return seed_js_1.MASTER_SEED_BYTES; } });
48
+ var schedule_js_1 = require("./schedule.cjs");
49
+ Object.defineProperty(exports, "createSchedule", { enumerable: true, get: function () { return schedule_js_1.createSchedule; } });
47
50
  var eventLog_js_1 = require("./eventLog.cjs");
48
51
  Object.defineProperty(exports, "defineEventLog", { enumerable: true, get: function () { return eventLog_js_1.defineEventLog; } });
49
52
  var index_js_1 = require("./traits/index.cjs");
@@ -4,6 +4,8 @@ export { chance, createRng, createSubstreams, getSubstream, nextFloat, nextInt,
4
4
  export type { RngLayers, RngLayersSnapshot, RngSeeds, RngStream, RngStreamSnapshot, Substreams, SubstreamsSnapshot, } from "./rng.cjs";
5
5
  export { createMasterSeed, deriveSeed, isMasterSeed, MASTER_SEED_BYTES } from "./seed.cjs";
6
6
  export type { EntropySource, SeedPart } from "./seed.cjs";
7
+ export { createSchedule } from "./schedule.cjs";
8
+ export type { Schedule, ScheduleOptions, System } from "./schedule.cjs";
7
9
  export { defineEventLog } from "./eventLog.cjs";
8
10
  export type { EventLog } from "./eventLog.cjs";
9
11
  export { defineTrait } from "./traits/index.cjs";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAaA,OAAO,EACL,YAAY,EACZ,aAAa,EACb,cAAc,EACd,eAAe,EACf,QAAQ,EACR,kBAAkB,EAClB,aAAa,EACb,KAAK,GACN,MAAM,YAAY,CAAC;AACpB,YAAY,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAE5E,OAAO,EACL,MAAM,EACN,SAAS,EACT,gBAAgB,EAChB,YAAY,EACZ,SAAS,EACT,OAAO,EACP,OAAO,EACP,IAAI,EACJ,aAAa,EACb,aAAa,EACb,iBAAiB,EACjB,OAAO,EACP,cAAc,EACd,cAAc,EACd,kBAAkB,EAClB,SAAS,GACV,MAAM,UAAU,CAAC;AAClB,YAAY,EACV,SAAS,EACT,iBAAiB,EACjB,QAAQ,EACR,SAAS,EACT,iBAAiB,EACjB,UAAU,EACV,kBAAkB,GACnB,MAAM,UAAU,CAAC;AAElB,OAAO,EAAE,gBAAgB,EAAE,UAAU,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAC1F,YAAY,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAEzD,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,YAAY,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAE9C,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,YAAY,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAcA,OAAO,EACL,YAAY,EACZ,aAAa,EACb,cAAc,EACd,eAAe,EACf,QAAQ,EACR,kBAAkB,EAClB,aAAa,EACb,KAAK,GACN,MAAM,YAAY,CAAC;AACpB,YAAY,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAE5E,OAAO,EACL,MAAM,EACN,SAAS,EACT,gBAAgB,EAChB,YAAY,EACZ,SAAS,EACT,OAAO,EACP,OAAO,EACP,IAAI,EACJ,aAAa,EACb,aAAa,EACb,iBAAiB,EACjB,OAAO,EACP,cAAc,EACd,cAAc,EACd,kBAAkB,EAClB,SAAS,GACV,MAAM,UAAU,CAAC;AAClB,YAAY,EACV,SAAS,EACT,iBAAiB,EACjB,QAAQ,EACR,SAAS,EACT,iBAAiB,EACjB,UAAU,EACV,kBAAkB,GACnB,MAAM,UAAU,CAAC;AAElB,OAAO,EAAE,gBAAgB,EAAE,UAAU,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAC1F,YAAY,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAEzD,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,YAAY,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAEvE,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,YAAY,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAE9C,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,YAAY,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC"}
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ // React bindings for the sim handle. This is the ONLY module that imports
3
+ // React (through koota/react), and nothing else in the package imports it, so
4
+ // the root entry and every other subpath stay React-free. `react` is an
5
+ // optional peer dependency: consumers who never import `koota-kit/react` never
6
+ // need it installed.
7
+ //
8
+ // koota/react's own `WorldProvider` / `useWorld` carry only the koota `World`.
9
+ // A component that needs the handle's RNG streams, seeds, clock or scratch
10
+ // cannot reach them from there. `SimWorldProvider` supplies the whole
11
+ // `WorldHandle` AND mounts koota's provider for `handle.world`, so
12
+ // `useTrait`, `useQuery` and `useActions` work underneath it unchanged.
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.SimWorldProvider = void 0;
15
+ exports.useSimWorld = useSimWorld;
16
+ const react_1 = require("koota/react");
17
+ const react_2 = require("react");
18
+ const SimWorldContext = (0, react_2.createContext)(null);
19
+ SimWorldContext.displayName = "SimWorldContext";
20
+ /**
21
+ * Provide a `WorldHandle` to the tree below. Renders koota's `WorldProvider`
22
+ * for `handle.world`, so every `koota/react` hook works under it without a
23
+ * second provider. The provider does not own the handle's lifecycle: create it
24
+ * with `createSimWorld` and release it with `destroySimWorld` yourself.
25
+ */
26
+ const SimWorldProvider = ({ handle, children }) => (0, react_2.createElement)(SimWorldContext.Provider, { value: handle }, (0, react_2.createElement)(react_1.WorldProvider, { world: handle.world, children }));
27
+ exports.SimWorldProvider = SimWorldProvider;
28
+ /**
29
+ * The `WorldHandle` provided by the nearest `SimWorldProvider`: its `world`,
30
+ * `rng`, `seeds`, `clock` and `scratch`. Throws when called outside one — a
31
+ * missing provider is a wiring bug, not a state to render around.
32
+ */
33
+ function useSimWorld() {
34
+ const handle = (0, react_2.useContext)(SimWorldContext);
35
+ if (handle === null) {
36
+ throw new Error("useSimWorld must be used within a <SimWorldProvider>");
37
+ }
38
+ return handle;
39
+ }
@@ -0,0 +1,21 @@
1
+ import type { FunctionComponent, ReactNode } from "react";
2
+ import type { WorldHandle } from "./world.cjs";
3
+ export type SimWorldProviderProps = {
4
+ /** The handle from `createSimWorld`. Swapping it re-provides every consumer. */
5
+ readonly handle: WorldHandle;
6
+ readonly children?: ReactNode;
7
+ };
8
+ /**
9
+ * Provide a `WorldHandle` to the tree below. Renders koota's `WorldProvider`
10
+ * for `handle.world`, so every `koota/react` hook works under it without a
11
+ * second provider. The provider does not own the handle's lifecycle: create it
12
+ * with `createSimWorld` and release it with `destroySimWorld` yourself.
13
+ */
14
+ export declare const SimWorldProvider: FunctionComponent<SimWorldProviderProps>;
15
+ /**
16
+ * The `WorldHandle` provided by the nearest `SimWorldProvider`: its `world`,
17
+ * `rng`, `seeds`, `clock` and `scratch`. Throws when called outside one — a
18
+ * missing provider is a wiring bug, not a state to render around.
19
+ */
20
+ export declare function useSimWorld(): WorldHandle;
21
+ //# sourceMappingURL=react.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../../src/react.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,iBAAiB,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAC1D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C,MAAM,MAAM,qBAAqB,GAAG;IAClC,gFAAgF;IAChF,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;IAC7B,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,CAAC;CAC/B,CAAC;AAKF;;;;;GAKG;AACH,eAAO,MAAM,gBAAgB,EAAE,iBAAiB,CAAC,qBAAqB,CAKnE,CAAC;AAEJ;;;;GAIG;AACH,wBAAgB,WAAW,IAAI,WAAW,CAMzC"}
@@ -0,0 +1,131 @@
1
+ "use strict";
2
+ // Fixed-step system schedule — the loop that turns "a frame passed" into
3
+ // "the sim advanced by whole steps".
4
+ //
5
+ // A simulation that integrates the frame's own dt drifts with the frame rate:
6
+ // a 120 Hz phone and a test that advances three seconds in one call disagree,
7
+ // and anything counted in real time (a save every 0.25 s) lands on different
8
+ // ticks. A fixed-step schedule removes the frame rate from the sim: the frame's
9
+ // dt fills an accumulator, and the sim only ever advances by whole `step`s.
10
+ //
11
+ // The loop is generic sim plumbing, so it lives here and not in the game: it
12
+ // knows nothing about React, a renderer, or what a system does. A renderer's
13
+ // frame callback calls `schedule.tick(handle, dt)` and that is the entire
14
+ // integration. The accumulator lives in the handle's `scratch`, so it dies with
15
+ // the run: a new world starts with nothing owed, and two schedules (or two
16
+ // worlds) never share one.
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.createSchedule = createSchedule;
19
+ const world_js_1 = require("./world.cjs");
20
+ let nextScheduleId = 0;
21
+ // Keys are process-wide because schedules are declared once at module scope and
22
+ // used on many handles. An explicit key may be shared on purpose; a generated
23
+ // one must stay private to its schedule.
24
+ const claimedKeys = new Set();
25
+ const generatedKeys = new Set();
26
+ // Float accumulation: 432 frames of 1/144 add up to a hair under 3 s, and
27
+ // without a tolerance the sim would owe — and run — one step fewer than the
28
+ // same time delivered at 60 Hz. A billionth of a step is far below anything a
29
+ // frame clock can resolve, so treating "within it" as whole costs nothing.
30
+ const STEP_TOLERANCE = 1e-9;
31
+ /**
32
+ * Build a fixed-step schedule over `systems`, run in the order given.
33
+ *
34
+ * ```ts
35
+ * const schedule = createSchedule([intro, stove, mixer, serving], {
36
+ * step: 1 / 60,
37
+ * maxSteps: 6,
38
+ * });
39
+ * // once per rendered frame, with the frame's dt:
40
+ * schedule.tick(run, frameDt);
41
+ * ```
42
+ *
43
+ * The systems array is copied; later changes to it do not affect the schedule.
44
+ */
45
+ function createSchedule(systems, options) {
46
+ if (!Array.isArray(systems)) {
47
+ throw new TypeError("createSchedule: systems must be an array of functions.");
48
+ }
49
+ const { step, maxSteps } = options;
50
+ if (!Number.isFinite(step) || step <= 0) {
51
+ throw new RangeError(`createSchedule: step must be finite and greater than zero; received ${step}.`);
52
+ }
53
+ if (!Number.isSafeInteger(maxSteps) || maxSteps < 1) {
54
+ throw new RangeError(`createSchedule: maxSteps must be a safe integer of at least 1; received ${maxSteps}.`);
55
+ }
56
+ if (options.key !== undefined && (typeof options.key !== "string" || options.key === "")) {
57
+ throw new TypeError("createSchedule: key must be a non-empty string when provided.");
58
+ }
59
+ const ordered = Object.freeze([...systems]);
60
+ for (const system of ordered) {
61
+ if (typeof system !== "function") {
62
+ throw new TypeError("createSchedule: every system must be a function.");
63
+ }
64
+ }
65
+ let key;
66
+ if (options.key === undefined) {
67
+ // Skip any key a caller has already named, so a default never lands on one.
68
+ do {
69
+ key = `schedule:${nextScheduleId++}`;
70
+ } while (claimedKeys.has(key));
71
+ generatedKeys.add(key);
72
+ }
73
+ else {
74
+ if (generatedKeys.has(options.key)) {
75
+ throw new TypeError(`createSchedule: key "${options.key}" is already the generated key of another schedule.`);
76
+ }
77
+ key = options.key;
78
+ }
79
+ claimedKeys.add(key);
80
+ const tolerance = step * STEP_TOLERANCE;
81
+ function read(handle) {
82
+ const stored = handle.scratch.get(key);
83
+ if (stored === undefined)
84
+ return 0;
85
+ if (typeof stored !== "number" || !Number.isFinite(stored) || stored < 0) {
86
+ throw new TypeError(`createSchedule: scratch key "${key}" already holds something other than a schedule accumulator.`);
87
+ }
88
+ return stored;
89
+ }
90
+ function tick(handle, frameDt) {
91
+ const previous = read(handle);
92
+ // A pause (and any frame dt that counts as 0) runs nothing, even if a
93
+ // failed tick left whole steps owed: those wait for a frame that moves time.
94
+ if (!(Number.isFinite(frameDt) && frameDt > 0))
95
+ return 0;
96
+ const sum = previous + frameDt;
97
+ // Two huge finite frames can sum past the double range; saturate instead of
98
+ // letting Infinity poison the stored accumulator.
99
+ let owed = Number.isFinite(sum) ? sum : Number.MAX_VALUE;
100
+ let ran = 0;
101
+ try {
102
+ while (owed + tolerance >= step && ran < maxSteps) {
103
+ // The clock moves first, so a step it refuses (an exhausted clock) is
104
+ // never debited. After that the step is spent before any system runs:
105
+ // if one throws, this step's time is gone with the tick it caused.
106
+ (0, world_js_1.advanceClock)(handle, step);
107
+ owed = Math.max(0, owed - step);
108
+ ran++;
109
+ for (const system of ordered)
110
+ system(handle, step);
111
+ }
112
+ if (owed + tolerance >= step) {
113
+ // Capped with whole steps still owed: drop them, keep the fraction.
114
+ owed %= step;
115
+ if (owed + tolerance >= step)
116
+ owed = 0;
117
+ }
118
+ }
119
+ finally {
120
+ handle.scratch.set(key, owed);
121
+ }
122
+ return ran;
123
+ }
124
+ return Object.freeze({
125
+ step,
126
+ maxSteps,
127
+ key,
128
+ tick,
129
+ remainder: read,
130
+ });
131
+ }
@@ -0,0 +1,69 @@
1
+ import type { WorldHandle } from "./world.cjs";
2
+ /**
3
+ * One unit of sim work, run once per fixed step.
4
+ *
5
+ * `dt` is always the schedule's `step`, never the frame's dt, so a system can
6
+ * integrate it without knowing the frame rate. Systems that need randomness
7
+ * should draw from a named substream rather than the handle's positional `rng`,
8
+ * so adding a system does not shift another system's draws.
9
+ */
10
+ export type System = (handle: WorldHandle, dt: number) => void;
11
+ export type ScheduleOptions = {
12
+ /** Seconds of sim time one step represents. Finite and greater than zero. */
13
+ step: number;
14
+ /**
15
+ * The most steps a single `tick` may run. A safe integer of at least 1. Time
16
+ * owed beyond this is dropped, not carried, so one huge frame dt (a hidden
17
+ * tab returning after a minute) cannot trigger a catch-up storm.
18
+ */
19
+ maxSteps: number;
20
+ /**
21
+ * The `scratch` key holding this schedule's accumulator, for callers who want
22
+ * to namespace it (`"run:schedule"`). Defaults to a key unique to this
23
+ * schedule. Two schedules given the same key share one accumulator.
24
+ */
25
+ key?: string;
26
+ };
27
+ export type Schedule = {
28
+ readonly step: number;
29
+ readonly maxSteps: number;
30
+ /** The `scratch` key holding this schedule's accumulator on a handle. */
31
+ readonly key: string;
32
+ /**
33
+ * Add `frameDt` seconds to `handle`'s accumulator and run every whole step it
34
+ * now covers, up to `maxSteps`: each step advances the clock by `step`, then
35
+ * calls every system in order with `dt === step`. Returns how many steps ran.
36
+ *
37
+ * A `frameDt` that is negative, `NaN` or infinite counts as `0`, and `0` is a
38
+ * valid pause: no step runs and the clock does not move. Whole steps beyond
39
+ * `maxSteps` are dropped; the sub-step remainder is kept.
40
+ *
41
+ * If a system throws, the steps that had already started are consumed (the
42
+ * clock moved for them) and the rest stay owed, so a retry does not replay
43
+ * time the sim already spent.
44
+ */
45
+ tick: (handle: WorldHandle, frameDt: number) => number;
46
+ /**
47
+ * Seconds currently owed to the next step, in `[0, step)` between ticks.
48
+ * Dividing it by `step` gives the interpolation alpha for a renderer drawing
49
+ * between the previous and current sim state. `0` on a handle that has not
50
+ * ticked.
51
+ */
52
+ remainder: (handle: WorldHandle) => number;
53
+ };
54
+ /**
55
+ * Build a fixed-step schedule over `systems`, run in the order given.
56
+ *
57
+ * ```ts
58
+ * const schedule = createSchedule([intro, stove, mixer, serving], {
59
+ * step: 1 / 60,
60
+ * maxSteps: 6,
61
+ * });
62
+ * // once per rendered frame, with the frame's dt:
63
+ * schedule.tick(run, frameDt);
64
+ * ```
65
+ *
66
+ * The systems array is copied; later changes to it do not affect the schedule.
67
+ */
68
+ export declare function createSchedule(systems: readonly System[], options: ScheduleOptions): Readonly<Schedule>;
69
+ //# sourceMappingURL=schedule.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schedule.d.ts","sourceRoot":"","sources":["../../src/schedule.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAG9C;;;;;;;GAOG;AACH,MAAM,MAAM,MAAM,GAAG,CAAC,MAAM,EAAE,WAAW,EAAE,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;AAE/D,MAAM,MAAM,eAAe,GAAG;IAC5B,6EAA6E;IAC7E,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,MAAM,MAAM,QAAQ,GAAG;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,yEAAyE;IACzE,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB;;;;;;;;;;;;OAYG;IACH,IAAI,EAAE,CAAC,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC;IACvD;;;;;OAKG;IACH,SAAS,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,MAAM,CAAC;CAC5C,CAAC;AAeF;;;;;;;;;;;;;GAaG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,OAAO,EAAE,eAAe,GACvB,QAAQ,CAAC,QAAQ,CAAC,CA2FpB"}
@@ -4,6 +4,8 @@ export { chance, createRng, createSubstreams, getSubstream, nextFloat, nextInt,
4
4
  export type { RngLayers, RngLayersSnapshot, RngSeeds, RngStream, RngStreamSnapshot, Substreams, SubstreamsSnapshot, } from "./rng.js";
5
5
  export { createMasterSeed, deriveSeed, isMasterSeed, MASTER_SEED_BYTES } from "./seed.js";
6
6
  export type { EntropySource, SeedPart } from "./seed.js";
7
+ export { createSchedule } from "./schedule.js";
8
+ export type { Schedule, ScheduleOptions, System } from "./schedule.js";
7
9
  export { defineEventLog } from "./eventLog.js";
8
10
  export type { EventLog } from "./eventLog.js";
9
11
  export { defineTrait } from "./traits/index.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAaA,OAAO,EACL,YAAY,EACZ,aAAa,EACb,cAAc,EACd,eAAe,EACf,QAAQ,EACR,kBAAkB,EAClB,aAAa,EACb,KAAK,GACN,MAAM,YAAY,CAAC;AACpB,YAAY,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAE5E,OAAO,EACL,MAAM,EACN,SAAS,EACT,gBAAgB,EAChB,YAAY,EACZ,SAAS,EACT,OAAO,EACP,OAAO,EACP,IAAI,EACJ,aAAa,EACb,aAAa,EACb,iBAAiB,EACjB,OAAO,EACP,cAAc,EACd,cAAc,EACd,kBAAkB,EAClB,SAAS,GACV,MAAM,UAAU,CAAC;AAClB,YAAY,EACV,SAAS,EACT,iBAAiB,EACjB,QAAQ,EACR,SAAS,EACT,iBAAiB,EACjB,UAAU,EACV,kBAAkB,GACnB,MAAM,UAAU,CAAC;AAElB,OAAO,EAAE,gBAAgB,EAAE,UAAU,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAC1F,YAAY,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAEzD,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,YAAY,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAE9C,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,YAAY,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAcA,OAAO,EACL,YAAY,EACZ,aAAa,EACb,cAAc,EACd,eAAe,EACf,QAAQ,EACR,kBAAkB,EAClB,aAAa,EACb,KAAK,GACN,MAAM,YAAY,CAAC;AACpB,YAAY,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAE5E,OAAO,EACL,MAAM,EACN,SAAS,EACT,gBAAgB,EAChB,YAAY,EACZ,SAAS,EACT,OAAO,EACP,OAAO,EACP,IAAI,EACJ,aAAa,EACb,aAAa,EACb,iBAAiB,EACjB,OAAO,EACP,cAAc,EACd,cAAc,EACd,kBAAkB,EAClB,SAAS,GACV,MAAM,UAAU,CAAC;AAClB,YAAY,EACV,SAAS,EACT,iBAAiB,EACjB,QAAQ,EACR,SAAS,EACT,iBAAiB,EACjB,UAAU,EACV,kBAAkB,GACnB,MAAM,UAAU,CAAC;AAElB,OAAO,EAAE,gBAAgB,EAAE,UAAU,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAC1F,YAAY,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAEzD,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,YAAY,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAEvE,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,YAAY,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAE9C,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,YAAY,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC"}
package/dist/esm/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // koota-kit — thin koota ECS conventions layer.
2
2
  //
3
- // Five modules, also importable as subpaths:
3
+ // Six modules, also importable as subpaths:
4
4
  // ./world — WorldHandle lifecycle (world + dual-stream rng + clock + scratch)
5
+ // ./schedule — fixed-step system schedule (accumulator in scratch, step cap)
5
6
  // ./rng — dual-layer deterministic seedrandom PRNG and named substreams
6
7
  // with byte-exact snapshot/restore
7
8
  // ./seed — length-prefixed SHA-256 seed derivation and 128-bit master
@@ -13,5 +14,6 @@
13
14
  export { advanceClock, createActions, createSimWorld, destroySimWorld, relation, restoreWorldHeader, snapshotWorld, trait, } from "./world.js";
14
15
  export { chance, createRng, createSubstreams, getSubstream, nextFloat, nextInt, nextU32, pick, restoreLayers, restoreStream, restoreSubstreams, shuffle, snapshotLayers, snapshotStream, snapshotSubstreams, substream, } from "./rng.js";
15
16
  export { createMasterSeed, deriveSeed, isMasterSeed, MASTER_SEED_BYTES } from "./seed.js";
17
+ export { createSchedule } from "./schedule.js";
16
18
  export { defineEventLog } from "./eventLog.js";
17
19
  export { defineTrait } from "./traits/index.js";
@@ -0,0 +1,21 @@
1
+ import type { FunctionComponent, ReactNode } from "react";
2
+ import type { WorldHandle } from "./world.js";
3
+ export type SimWorldProviderProps = {
4
+ /** The handle from `createSimWorld`. Swapping it re-provides every consumer. */
5
+ readonly handle: WorldHandle;
6
+ readonly children?: ReactNode;
7
+ };
8
+ /**
9
+ * Provide a `WorldHandle` to the tree below. Renders koota's `WorldProvider`
10
+ * for `handle.world`, so every `koota/react` hook works under it without a
11
+ * second provider. The provider does not own the handle's lifecycle: create it
12
+ * with `createSimWorld` and release it with `destroySimWorld` yourself.
13
+ */
14
+ export declare const SimWorldProvider: FunctionComponent<SimWorldProviderProps>;
15
+ /**
16
+ * The `WorldHandle` provided by the nearest `SimWorldProvider`: its `world`,
17
+ * `rng`, `seeds`, `clock` and `scratch`. Throws when called outside one — a
18
+ * missing provider is a wiring bug, not a state to render around.
19
+ */
20
+ export declare function useSimWorld(): WorldHandle;
21
+ //# sourceMappingURL=react.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../../src/react.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,iBAAiB,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAC1D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C,MAAM,MAAM,qBAAqB,GAAG;IAClC,gFAAgF;IAChF,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;IAC7B,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,CAAC;CAC/B,CAAC;AAKF;;;;;GAKG;AACH,eAAO,MAAM,gBAAgB,EAAE,iBAAiB,CAAC,qBAAqB,CAKnE,CAAC;AAEJ;;;;GAIG;AACH,wBAAgB,WAAW,IAAI,WAAW,CAMzC"}
@@ -0,0 +1,34 @@
1
+ // React bindings for the sim handle. This is the ONLY module that imports
2
+ // React (through koota/react), and nothing else in the package imports it, so
3
+ // the root entry and every other subpath stay React-free. `react` is an
4
+ // optional peer dependency: consumers who never import `koota-kit/react` never
5
+ // need it installed.
6
+ //
7
+ // koota/react's own `WorldProvider` / `useWorld` carry only the koota `World`.
8
+ // A component that needs the handle's RNG streams, seeds, clock or scratch
9
+ // cannot reach them from there. `SimWorldProvider` supplies the whole
10
+ // `WorldHandle` AND mounts koota's provider for `handle.world`, so
11
+ // `useTrait`, `useQuery` and `useActions` work underneath it unchanged.
12
+ import { WorldProvider } from "koota/react";
13
+ import { createContext, createElement, useContext } from "react";
14
+ const SimWorldContext = createContext(null);
15
+ SimWorldContext.displayName = "SimWorldContext";
16
+ /**
17
+ * Provide a `WorldHandle` to the tree below. Renders koota's `WorldProvider`
18
+ * for `handle.world`, so every `koota/react` hook works under it without a
19
+ * second provider. The provider does not own the handle's lifecycle: create it
20
+ * with `createSimWorld` and release it with `destroySimWorld` yourself.
21
+ */
22
+ export const SimWorldProvider = ({ handle, children }) => createElement(SimWorldContext.Provider, { value: handle }, createElement(WorldProvider, { world: handle.world, children }));
23
+ /**
24
+ * The `WorldHandle` provided by the nearest `SimWorldProvider`: its `world`,
25
+ * `rng`, `seeds`, `clock` and `scratch`. Throws when called outside one — a
26
+ * missing provider is a wiring bug, not a state to render around.
27
+ */
28
+ export function useSimWorld() {
29
+ const handle = useContext(SimWorldContext);
30
+ if (handle === null) {
31
+ throw new Error("useSimWorld must be used within a <SimWorldProvider>");
32
+ }
33
+ return handle;
34
+ }
@@ -0,0 +1,69 @@
1
+ import type { WorldHandle } from "./world.js";
2
+ /**
3
+ * One unit of sim work, run once per fixed step.
4
+ *
5
+ * `dt` is always the schedule's `step`, never the frame's dt, so a system can
6
+ * integrate it without knowing the frame rate. Systems that need randomness
7
+ * should draw from a named substream rather than the handle's positional `rng`,
8
+ * so adding a system does not shift another system's draws.
9
+ */
10
+ export type System = (handle: WorldHandle, dt: number) => void;
11
+ export type ScheduleOptions = {
12
+ /** Seconds of sim time one step represents. Finite and greater than zero. */
13
+ step: number;
14
+ /**
15
+ * The most steps a single `tick` may run. A safe integer of at least 1. Time
16
+ * owed beyond this is dropped, not carried, so one huge frame dt (a hidden
17
+ * tab returning after a minute) cannot trigger a catch-up storm.
18
+ */
19
+ maxSteps: number;
20
+ /**
21
+ * The `scratch` key holding this schedule's accumulator, for callers who want
22
+ * to namespace it (`"run:schedule"`). Defaults to a key unique to this
23
+ * schedule. Two schedules given the same key share one accumulator.
24
+ */
25
+ key?: string;
26
+ };
27
+ export type Schedule = {
28
+ readonly step: number;
29
+ readonly maxSteps: number;
30
+ /** The `scratch` key holding this schedule's accumulator on a handle. */
31
+ readonly key: string;
32
+ /**
33
+ * Add `frameDt` seconds to `handle`'s accumulator and run every whole step it
34
+ * now covers, up to `maxSteps`: each step advances the clock by `step`, then
35
+ * calls every system in order with `dt === step`. Returns how many steps ran.
36
+ *
37
+ * A `frameDt` that is negative, `NaN` or infinite counts as `0`, and `0` is a
38
+ * valid pause: no step runs and the clock does not move. Whole steps beyond
39
+ * `maxSteps` are dropped; the sub-step remainder is kept.
40
+ *
41
+ * If a system throws, the steps that had already started are consumed (the
42
+ * clock moved for them) and the rest stay owed, so a retry does not replay
43
+ * time the sim already spent.
44
+ */
45
+ tick: (handle: WorldHandle, frameDt: number) => number;
46
+ /**
47
+ * Seconds currently owed to the next step, in `[0, step)` between ticks.
48
+ * Dividing it by `step` gives the interpolation alpha for a renderer drawing
49
+ * between the previous and current sim state. `0` on a handle that has not
50
+ * ticked.
51
+ */
52
+ remainder: (handle: WorldHandle) => number;
53
+ };
54
+ /**
55
+ * Build a fixed-step schedule over `systems`, run in the order given.
56
+ *
57
+ * ```ts
58
+ * const schedule = createSchedule([intro, stove, mixer, serving], {
59
+ * step: 1 / 60,
60
+ * maxSteps: 6,
61
+ * });
62
+ * // once per rendered frame, with the frame's dt:
63
+ * schedule.tick(run, frameDt);
64
+ * ```
65
+ *
66
+ * The systems array is copied; later changes to it do not affect the schedule.
67
+ */
68
+ export declare function createSchedule(systems: readonly System[], options: ScheduleOptions): Readonly<Schedule>;
69
+ //# sourceMappingURL=schedule.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schedule.d.ts","sourceRoot":"","sources":["../../src/schedule.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAG9C;;;;;;;GAOG;AACH,MAAM,MAAM,MAAM,GAAG,CAAC,MAAM,EAAE,WAAW,EAAE,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;AAE/D,MAAM,MAAM,eAAe,GAAG;IAC5B,6EAA6E;IAC7E,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,MAAM,MAAM,QAAQ,GAAG;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,yEAAyE;IACzE,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB;;;;;;;;;;;;OAYG;IACH,IAAI,EAAE,CAAC,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC;IACvD;;;;;OAKG;IACH,SAAS,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,MAAM,CAAC;CAC5C,CAAC;AAeF;;;;;;;;;;;;;GAaG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,OAAO,EAAE,eAAe,GACvB,QAAQ,CAAC,QAAQ,CAAC,CA2FpB"}
@@ -0,0 +1,128 @@
1
+ // Fixed-step system schedule — the loop that turns "a frame passed" into
2
+ // "the sim advanced by whole steps".
3
+ //
4
+ // A simulation that integrates the frame's own dt drifts with the frame rate:
5
+ // a 120 Hz phone and a test that advances three seconds in one call disagree,
6
+ // and anything counted in real time (a save every 0.25 s) lands on different
7
+ // ticks. A fixed-step schedule removes the frame rate from the sim: the frame's
8
+ // dt fills an accumulator, and the sim only ever advances by whole `step`s.
9
+ //
10
+ // The loop is generic sim plumbing, so it lives here and not in the game: it
11
+ // knows nothing about React, a renderer, or what a system does. A renderer's
12
+ // frame callback calls `schedule.tick(handle, dt)` and that is the entire
13
+ // integration. The accumulator lives in the handle's `scratch`, so it dies with
14
+ // the run: a new world starts with nothing owed, and two schedules (or two
15
+ // worlds) never share one.
16
+ import { advanceClock } from "./world.js";
17
+ let nextScheduleId = 0;
18
+ // Keys are process-wide because schedules are declared once at module scope and
19
+ // used on many handles. An explicit key may be shared on purpose; a generated
20
+ // one must stay private to its schedule.
21
+ const claimedKeys = new Set();
22
+ const generatedKeys = new Set();
23
+ // Float accumulation: 432 frames of 1/144 add up to a hair under 3 s, and
24
+ // without a tolerance the sim would owe — and run — one step fewer than the
25
+ // same time delivered at 60 Hz. A billionth of a step is far below anything a
26
+ // frame clock can resolve, so treating "within it" as whole costs nothing.
27
+ const STEP_TOLERANCE = 1e-9;
28
+ /**
29
+ * Build a fixed-step schedule over `systems`, run in the order given.
30
+ *
31
+ * ```ts
32
+ * const schedule = createSchedule([intro, stove, mixer, serving], {
33
+ * step: 1 / 60,
34
+ * maxSteps: 6,
35
+ * });
36
+ * // once per rendered frame, with the frame's dt:
37
+ * schedule.tick(run, frameDt);
38
+ * ```
39
+ *
40
+ * The systems array is copied; later changes to it do not affect the schedule.
41
+ */
42
+ export function createSchedule(systems, options) {
43
+ if (!Array.isArray(systems)) {
44
+ throw new TypeError("createSchedule: systems must be an array of functions.");
45
+ }
46
+ const { step, maxSteps } = options;
47
+ if (!Number.isFinite(step) || step <= 0) {
48
+ throw new RangeError(`createSchedule: step must be finite and greater than zero; received ${step}.`);
49
+ }
50
+ if (!Number.isSafeInteger(maxSteps) || maxSteps < 1) {
51
+ throw new RangeError(`createSchedule: maxSteps must be a safe integer of at least 1; received ${maxSteps}.`);
52
+ }
53
+ if (options.key !== undefined && (typeof options.key !== "string" || options.key === "")) {
54
+ throw new TypeError("createSchedule: key must be a non-empty string when provided.");
55
+ }
56
+ const ordered = Object.freeze([...systems]);
57
+ for (const system of ordered) {
58
+ if (typeof system !== "function") {
59
+ throw new TypeError("createSchedule: every system must be a function.");
60
+ }
61
+ }
62
+ let key;
63
+ if (options.key === undefined) {
64
+ // Skip any key a caller has already named, so a default never lands on one.
65
+ do {
66
+ key = `schedule:${nextScheduleId++}`;
67
+ } while (claimedKeys.has(key));
68
+ generatedKeys.add(key);
69
+ }
70
+ else {
71
+ if (generatedKeys.has(options.key)) {
72
+ throw new TypeError(`createSchedule: key "${options.key}" is already the generated key of another schedule.`);
73
+ }
74
+ key = options.key;
75
+ }
76
+ claimedKeys.add(key);
77
+ const tolerance = step * STEP_TOLERANCE;
78
+ function read(handle) {
79
+ const stored = handle.scratch.get(key);
80
+ if (stored === undefined)
81
+ return 0;
82
+ if (typeof stored !== "number" || !Number.isFinite(stored) || stored < 0) {
83
+ throw new TypeError(`createSchedule: scratch key "${key}" already holds something other than a schedule accumulator.`);
84
+ }
85
+ return stored;
86
+ }
87
+ function tick(handle, frameDt) {
88
+ const previous = read(handle);
89
+ // A pause (and any frame dt that counts as 0) runs nothing, even if a
90
+ // failed tick left whole steps owed: those wait for a frame that moves time.
91
+ if (!(Number.isFinite(frameDt) && frameDt > 0))
92
+ return 0;
93
+ const sum = previous + frameDt;
94
+ // Two huge finite frames can sum past the double range; saturate instead of
95
+ // letting Infinity poison the stored accumulator.
96
+ let owed = Number.isFinite(sum) ? sum : Number.MAX_VALUE;
97
+ let ran = 0;
98
+ try {
99
+ while (owed + tolerance >= step && ran < maxSteps) {
100
+ // The clock moves first, so a step it refuses (an exhausted clock) is
101
+ // never debited. After that the step is spent before any system runs:
102
+ // if one throws, this step's time is gone with the tick it caused.
103
+ advanceClock(handle, step);
104
+ owed = Math.max(0, owed - step);
105
+ ran++;
106
+ for (const system of ordered)
107
+ system(handle, step);
108
+ }
109
+ if (owed + tolerance >= step) {
110
+ // Capped with whole steps still owed: drop them, keep the fraction.
111
+ owed %= step;
112
+ if (owed + tolerance >= step)
113
+ owed = 0;
114
+ }
115
+ }
116
+ finally {
117
+ handle.scratch.set(key, owed);
118
+ }
119
+ return ran;
120
+ }
121
+ return Object.freeze({
122
+ step,
123
+ maxSteps,
124
+ key,
125
+ tick,
126
+ remainder: read,
127
+ });
128
+ }
package/docs/API.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # API reference
2
2
 
3
- All functions and types are exported from `koota-kit`. The
4
- module-specific import paths shown below can reduce coupling in larger
5
- codebases.
3
+ All functions and types are exported from `koota-kit`, except the React
4
+ bindings, which are only available from `koota-kit/react` so the root entry
5
+ never loads React. The module-specific import paths shown below can reduce
6
+ coupling in larger codebases.
6
7
 
7
8
  ## `world`
8
9
 
@@ -173,6 +174,63 @@ const Pose = defineTrait(() => ({ position: { x: 0, y: 0 } }));
173
174
  Bare object and array fields are rejected by `SafeSchema` and by the runtime
174
175
  guard.
175
176
 
177
+ ## `schedule`
178
+
179
+ Import from `koota-kit/schedule` or the root.
180
+
181
+ ```ts
182
+ type System = (handle: WorldHandle, dt: number) => void;
183
+
184
+ type ScheduleOptions = {
185
+ step: number; // seconds per step; finite, > 0
186
+ maxSteps: number; // safe integer >= 1: most steps one tick may run
187
+ key?: string; // scratch key for the accumulator; unique by default
188
+ };
189
+
190
+ type Schedule = {
191
+ readonly step: number;
192
+ readonly maxSteps: number;
193
+ readonly key: string;
194
+ tick(handle: WorldHandle, frameDt: number): number;
195
+ remainder(handle: WorldHandle): number;
196
+ };
197
+
198
+ function createSchedule(systems: readonly System[], options: ScheduleOptions): Readonly<Schedule>;
199
+ ```
200
+
201
+ `createSchedule` copies `systems` and validates `step`, `maxSteps`, `key` and
202
+ every system before returning; bad configuration throws `TypeError` or
203
+ `RangeError` naming `createSchedule`.
204
+
205
+ `tick(handle, frameDt)` adds `frameDt` to an accumulator stored in
206
+ `handle.scratch` under `key`, then, while at least one whole `step` is owed and
207
+ fewer than `maxSteps` have run, calls `advanceClock(handle, step)` and then
208
+ every system in order with `dt === step`. It returns the number of steps run.
209
+ Systems read `dt`, never the frame's dt, so the same elapsed time gives the same
210
+ run at any frame rate, provided no single tick owes more than `maxSteps` (the
211
+ excess is dropped, so one long frame and many short ones then differ).
212
+
213
+ - A `frameDt` that is negative, `NaN` or infinite counts as `0`. `0` is a valid
214
+ pause: no step runs and the clock does not move, even if a failed tick left
215
+ whole steps owed; those wait for the next frame that moves time.
216
+ - When `maxSteps` is reached with whole steps still owed, those steps are
217
+ dropped, not carried. The sub-step fraction is kept, so phase is preserved.
218
+ - Owed time saturates at the largest finite double instead of overflowing.
219
+ - A billionth of a step of tolerance absorbs float drift, so 432 frames of
220
+ 1/144 s run the same 180 steps as 180 frames of 1/60 s.
221
+ - The accumulator lives in `scratch`, so it dies with the world and two
222
+ handles never share one. A `key` already holding something other than a
223
+ schedule accumulator throws `TypeError` before the clock moves. Two schedules
224
+ given the same explicit `key` share one accumulator, but an explicit `key`
225
+ may not equal another schedule's generated key, and a generated key skips any
226
+ explicit one.
227
+ - If a system throws, the step it was in is already spent (the clock moved) and
228
+ the steps not yet started stay owed, so a retry does not replay spent time.
229
+
230
+ `remainder(handle)` returns the seconds currently owed, in `[0, step)` between
231
+ ticks (`0` on a handle that has not ticked). `remainder / step` is the
232
+ interpolation alpha for a renderer drawing between sim states.
233
+
176
234
  ## `eventLog`
177
235
 
178
236
  ```ts
@@ -192,3 +250,27 @@ Logs are created once at module scope but store their arrays in each handle's
192
250
  scratch map. `drain` truncates the live array in place so an existing internal
193
251
  reference is not orphaned. Empty reads do not create a scratch entry. `peek`
194
252
  returns a detached array; `size` does not allocate.
253
+
254
+ ## `react`
255
+
256
+ Import from `koota-kit/react`. Requires `react` (`>=18`), an optional peer
257
+ dependency; no other entry point imports it.
258
+
259
+ ```ts
260
+ type SimWorldProviderProps = {
261
+ readonly handle: WorldHandle;
262
+ readonly children?: ReactNode;
263
+ };
264
+
265
+ const SimWorldProvider: FunctionComponent<SimWorldProviderProps>;
266
+ function useSimWorld(): WorldHandle;
267
+ ```
268
+
269
+ `SimWorldProvider` supplies `handle` to the tree below it and renders
270
+ `koota/react`'s `WorldProvider` for `handle.world`, so `useWorld`, `useTrait`,
271
+ `useQuery` and `useActions` work under it without a second provider.
272
+ `useSimWorld` returns the provided handle, including its `rng`, `seeds`,
273
+ `clock` and `scratch`, and throws an `Error` when no `SimWorldProvider` is above
274
+ it. Passing a different `handle` re-provides every consumer. The provider does
275
+ not create or destroy the handle: pair `createSimWorld` with `destroySimWorld`
276
+ yourself.
@@ -11,8 +11,10 @@ traits ───────────────> koota
11
11
  seed ─────────────────> (leaf: built-in SHA-256)
12
12
  rng ──────────────────> seedrandom + seed
13
13
  world ────────────────> koota + rng
14
+ schedule ─────────────> world (advanceClock) + WorldHandle scratch
14
15
  eventLog ─────────────> WorldHandle scratch
15
- index ────────────────> public re-exports only
16
+ react ────────────────> koota/react + react + world (type only)
17
+ index ────────────────> public re-exports only (never react)
16
18
  ```
17
19
 
18
20
  `traits` is a leaf module: it imports Koota but no sibling runtime module. This
@@ -33,9 +35,25 @@ text) is a stable contract: changing it would change every derived world.
33
35
  `world` owns allocation, teardown, time, and facade-owned persistence. It does
34
36
  not own application entity serialization.
35
37
 
38
+ `schedule` turns a frame's dt into whole fixed steps. Its accumulator lives in
39
+ the handle's scratch map, so time owed dies with the run and two schedules or
40
+ two worlds never share it. It imports no renderer: a frame callback calls
41
+ `tick` and that is the whole integration. The step cap drops excess time rather
42
+ than carrying it, so one huge dt cannot cause a catch-up storm.
43
+
36
44
  `eventLog` uses the handle's scratch map so event lifetime matches world
37
45
  lifetime. It does not use a process-global queue.
38
46
 
47
+ `react` is the only module that imports React, through `koota/react`. It is
48
+ reachable only from the `koota-kit/react` subpath: `index` does not re-export it,
49
+ and `react` is an optional peer dependency, so an application that never imports
50
+ that subpath never needs React installed. It carries the `WorldHandle` through a
51
+ context and mounts Koota's own `WorldProvider` for `handle.world`, which keeps
52
+ one source of truth for the world. It imports `world` for types only and owns no
53
+ lifecycle: creating and destroying the handle stays with the application.
54
+ `pnpm package:check` walks every other built entry's import graph and fails if
55
+ it reaches `react` or `koota/react`.
56
+
39
57
  ## Invariants
40
58
 
41
59
  1. Seed values on a handle are copied and frozen at creation.
@@ -47,6 +65,11 @@ lifetime. It does not use a process-global queue.
47
65
  7. World teardown clears facade-owned scratch state and releases the Koota ID.
48
66
  8. A named substream's sequence depends only on its seed and name; creating or
49
67
  consuming another substream never shifts it.
68
+ 9. Only `koota-kit/react` imports React; every other entry stays React-free.
69
+ 10. A schedule's systems only ever see `dt === step`; the same elapsed time
70
+ delivered at any frame rate runs the same steps in the same order, as long
71
+ as no single tick owes more than `maxSteps` (beyond that, the excess is
72
+ dropped by design, so a one-second frame and sixty 1/60 s frames differ).
50
73
 
51
74
  ## Performance choices
52
75
 
@@ -61,8 +84,10 @@ lifetime. It does not use a process-global queue.
61
84
  ## Intentional limits
62
85
 
63
86
  - No entity/trait serializer: applications need versioned domain schemas.
64
- - No scheduler or system runner: Koota's queries/actions remain the execution
65
- model.
87
+ - No system graph: `createSchedule` runs an ordered list once per fixed step,
88
+ and nothing more. There is no dependency resolution, parallelism, or
89
+ per-system rate; a system that wants a slower cadence counts steps itself.
90
+ Koota's queries/actions remain the execution model inside each system.
66
91
  - No implicit derived keys: `deriveSeed` hashes only the parts the application
67
92
  passes, so the application still owns stable domain keys.
68
93
  - No multi-consumer queue semantics: publish separate logs when two systems
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koota-kit",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Deterministic Koota simulation conventions: world lifecycle, dual RNG streams, safe object traits, and world-scoped event logs.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -63,6 +63,16 @@
63
63
  "default": "./dist/cjs/traits/index.cjs"
64
64
  }
65
65
  },
66
+ "./schedule": {
67
+ "import": {
68
+ "types": "./dist/esm/schedule.d.ts",
69
+ "default": "./dist/esm/schedule.js"
70
+ },
71
+ "require": {
72
+ "types": "./dist/cjs/schedule.d.cts",
73
+ "default": "./dist/cjs/schedule.cjs"
74
+ }
75
+ },
66
76
  "./eventLog": {
67
77
  "import": {
68
78
  "types": "./dist/esm/eventLog.d.ts",
@@ -73,6 +83,16 @@
73
83
  "default": "./dist/cjs/eventLog.cjs"
74
84
  }
75
85
  },
86
+ "./react": {
87
+ "import": {
88
+ "types": "./dist/esm/react.d.ts",
89
+ "default": "./dist/esm/react.js"
90
+ },
91
+ "require": {
92
+ "types": "./dist/cjs/react.d.cts",
93
+ "default": "./dist/cjs/react.cjs"
94
+ }
95
+ },
76
96
  "./package.json": "./package.json"
77
97
  },
78
98
  "files": [
@@ -109,18 +129,33 @@
109
129
  "seedrandom": "^3.0.5"
110
130
  },
111
131
  "peerDependencies": {
112
- "koota": "^0.6.6"
132
+ "@types/react": ">=18.0.0",
133
+ "koota": "^0.6.6",
134
+ "react": ">=18.0.0"
135
+ },
136
+ "peerDependenciesMeta": {
137
+ "@types/react": {
138
+ "optional": true
139
+ },
140
+ "react": {
141
+ "optional": true
142
+ }
113
143
  },
114
144
  "devDependencies": {
115
145
  "@arethetypeswrong/cli": "^0.18.5",
116
146
  "@biomejs/biome": "^2.5.5",
117
147
  "@commitlint/cli": "^21.2.2",
118
148
  "@commitlint/config-conventional": "^21.2.2",
149
+ "@types/react": "^19.3.0",
150
+ "@types/react-dom": "^19.3.0",
119
151
  "@vitest/coverage-v8": "^3.2.7",
152
+ "jsdom": "^30.1.2",
120
153
  "koota": "^0.6.6",
121
154
  "lint-staged": "^17.3.0",
122
155
  "markdownlint-cli2": "^0.23.2",
123
156
  "publint": "^0.3.24",
157
+ "react": "^19.3.0",
158
+ "react-dom": "^19.3.0",
124
159
  "simple-git-hooks": "^2.13.1",
125
160
  "typescript": "^5.9.2",
126
161
  "vitest": "^3.2.4"
@@ -163,9 +198,15 @@
163
198
  "traits": [
164
199
  "./dist/esm/traits/index.d.ts"
165
200
  ],
201
+ "schedule": [
202
+ "./dist/esm/schedule.d.ts"
203
+ ],
166
204
  "eventLog": [
167
205
  "./dist/esm/eventLog.d.ts"
168
206
  ],
207
+ "react": [
208
+ "./dist/esm/react.d.ts"
209
+ ],
169
210
  "*": [
170
211
  "./dist/esm/index.d.ts"
171
212
  ]