@daneren2005/shared-memory-ecs 1.8.2 → 1.10.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/README.md CHANGED
@@ -193,6 +193,195 @@ template.
193
193
 
194
194
  Classless factories keep the original behavior: they create `BaseEntity` and scan the complete registry.
195
195
 
196
+ ## System scheduling
197
+
198
+ `world.update(elapsedTime)` uses `DefaultScheduler` to dispatch systems in registration order.
199
+ Synchronous systems run immediately, workers can overlap after dispatch, and iterable systems can
200
+ continue across updates. Systems keep their existing `shouldRun()`, `deltaBetweenRuns`, and `firstRun`
201
+ behavior. The world advances clocks, applies `timeScale`, skips new updates while paused, prepares shared
202
+ memory, and manages deferred component frees.
203
+
204
+ Select a scheduler at construction; `world.scheduler` is readonly:
205
+
206
+ ```ts
207
+ import { BaseWorld, DefaultScheduler } from '@daneren2005/shared-memory-ecs';
208
+
209
+ const world = new BaseWorld(registry, {
210
+ scheduler: new DefaultScheduler<Components>()
211
+ });
212
+ world.update(16); // Synchronous result: { lastSystemError: null } when no dispatch error occurred.
213
+ ```
214
+
215
+ Custom policies implement `Scheduler<C>`. Its synchronous `update(context, elapsedTime)` receives the
216
+ scaled delta and a `SchedulerContext<C>` exposing the live `systems` array, the world's `emit` method,
217
+ and optional `gameTime` (supplied by `BaseWorld`).
218
+ It returns `SchedulerUpdateResult` (`{ lastSystemError?: Error | null }`). `DefaultScheduler` preserves
219
+ the existing dispatch timing events, error reporting, and continuation after a system throws.
220
+
221
+ Optional `systemAdded(context, system)` and `systemRemoved(context, system)` hooks receive notifications
222
+ after the array changes and before the corresponding world event. Duplicate names remain supported;
223
+ `addSystemIfNotExists` checks names and `removeSystem` removes the first matching name. Direct assignment
224
+ or mutation of `world.systems` bypasses membership hooks, so policies must read the live array when needed.
225
+ The default policy preserves native `forEach` mutation behavior during dispatch.
226
+
227
+ Optional `reset(context)` runs after systems are cleared during reload or non-pristine `clear()`;
228
+ pristine worlds skip that teardown. `destroy(context)` runs once after system destruction.
229
+ `runCompleted(context, system)` receives the existing completion boundary: a synchronous run returned,
230
+ an iterable queue drained, or a current-generation worker reply's effects were applied. Fallback
231
+ completion can arrive inside scheduler dispatch. The world processes eligible deferred frees before
232
+ calling this hook. Dispatch return values and `system-<name>-finished` events do not imply completion.
233
+ These hooks expose lifecycle notifications; they do not establish deterministic ticks or safe edit barriers.
234
+
235
+ Use `ConflictScheduler` to prevent systems from overlapping when one writes a component the other reads
236
+ or writes. Conflicts follow registration order; read-only systems and systems accessing independent
237
+ components can overlap:
238
+
239
+ ```ts
240
+ import { BaseWorld, ConflictScheduler, EntityWorkerSystem } from '@daneren2005/shared-memory-ecs';
241
+
242
+ const world = new BaseWorld(registry, {
243
+ scheduler: new ConflictScheduler<Components>()
244
+ });
245
+
246
+ class DamageSystem extends EntityWorkerSystem<Components, { health: Int32Array }> {
247
+ constructor() {
248
+ super(world, {
249
+ name: 'Damage',
250
+ required: ['health'],
251
+ writes: ['health'],
252
+ updateFunction: damageUpdate,
253
+ getWorker: () => new Worker(new URL('./damage-worker.ts', import.meta.url), { type: 'module' })
254
+ });
255
+ }
256
+ }
257
+ world.addSystem(new DamageSystem());
258
+ ```
259
+
260
+ The scheduler compiles its schedule when systems are added or removed. Inspect it before the first
261
+ update using the scheduler instance:
262
+
263
+ ```ts
264
+ const scheduler = new ConflictScheduler<Components>();
265
+ const world = new BaseWorld(registry, { scheduler });
266
+ // Register your systems here.
267
+ console.log(scheduler.schedule.order.map(system => system.name));
268
+ console.table(scheduler.schedule.batches.map((batch, index) => ({
269
+ batch: index,
270
+ systems: batch.map(system => system.name).join(', ')
271
+ })));
272
+ ```
273
+
274
+ `schedule` is a frozen `ConflictSchedule<C>` containing system references. `order` flattens the
275
+ compiled dependency levels, and `batches` group systems that can run concurrently within one update.
276
+ Every conflict points from the earlier registered system to the later one. Each system goes into the
277
+ earliest level after its conflicting predecessors. These groups describe dependencies, not runtime
278
+ barriers: a system starts when its own predecessors finish, even if unrelated systems in an earlier
279
+ group are still running. Actual dispatch can also overlap independent work from later host updates.
280
+
281
+ `writes` lists every component a system changes, including components reached outside its main query.
282
+ Use `writes: []` for a read-only system. Reads are inferred from `EntitySystem.components` and from worker
283
+ `required`, `optional`, and all named `queries`. The built-in `entity` component is also a read. Add
284
+ `reads: ['otherComponent']` for access outside those queries, including filters and hooks. A plain
285
+ `System` or `IterableSystem` supplies both `reads` and `writes`; an `EntitySystem` without a component
286
+ query supplies `reads` explicitly. Component names are checked against the system's component type.
287
+
288
+ Omitting `writes`, or leaving reads unknown, makes a system exclusive. `exclusive: true` also reserves
289
+ exclusive execution for a system whose callbacks, structural changes, or shared resources cannot be
290
+ described through component access. Workers with `createsEntities: true` or `addsComponents: true` remain
291
+ exclusive. Declare writes to `entity` when changing its flags; arbitrary callback effects still need
292
+ explicit declarations or exclusive execution.
293
+
294
+ For parallel worker creation, `EntityWorkerSystem` and `WorkerSystem` accept a component list in the
295
+ existing capability option, for example `createsEntities: ['health', 'movement']`. List every game
296
+ component the system can create, including components supplied by factory templates. The built-in
297
+ `entity` component is included automatically. `writes` still declares changes to existing components;
298
+ creation declarations do not authorize undeclared callback or attachment effects on existing data.
299
+ Both boolean and list forms enable the same worker allocator, factory templates, and stable atomic eids.
300
+ The default scheduler treats both forms identically and adopts results immediately on completion.
301
+
302
+ Under `ConflictScheduler`, compatible creators can allocate and initialize blocks concurrently, even
303
+ in the same component pool. Publication stays in scheduling order and waits for earlier readers and
304
+ writers of the affected components; later readers and writers wait until publication is applied.
305
+ A creator can therefore compute beside an earlier reader without changing that reader's query snapshot.
306
+ A later query waits for publication when the creator's component list can supply every required
307
+ component of that query. For example, creating `['transform', 'item']` does not block a query requiring
308
+ `['transform', 'tree']`, even when it optionally reads `item` or excludes it with `not`. Main and named
309
+ queries are checked separately; a match with any one retains the dependency. An empty or entity-only
310
+ query still observes every creation. Explicit `reads` outside queries and all `writes` retain their
311
+ component conflicts, so accessing `world.entities` directly must still be declared.
312
+
313
+ Creation lists describe all components a system can create, not one exact entity shape. Optional
314
+ components and exclusions therefore cannot prove a query unrelated when its required components could
315
+ all be created. Custom query filters need explicit `reads` for accesses outside their declared
316
+ components. Custom membership overrides retain conservative component-level creation access unless
317
+ they provide accurate `creationReadQueries` metadata. This optional system getter exposes separate
318
+ required-component lists to the conflict scheduler; `undefined` uses `creationReadComponents` as before.
319
+ Queued updates keep their original query and creation snapshots across schedule rebuilds.
320
+
321
+ Worker completion effects are kept together, preserving component-change, event, death, and adoption
322
+ order within each run. Attachment hooks and callbacks affecting other shared data still need truthful
323
+ `writes` or `exclusive: true`. Completion promises and memory-free notifications settle only after
324
+ application; cancellation discards unpublished blocks. These rules apply only to the conflict scheduler.
325
+
326
+ Each host update queues one update per registered system with its scaled delta and logical game time.
327
+ Updates of the same system stay sequential. Read/write and write/write conflicts preserve host-update
328
+ order, then registration order within an update, including dependencies on work that has not started
329
+ yet. Read/read access across different systems can overlap. Independent systems can advance into later
330
+ updates while a slow worker is unfinished; there is no whole-update or whole-batch completion barrier.
331
+ Exclusive work remains a barrier to all earlier and later work, including workers using the boolean
332
+ creation capability.
333
+
334
+ When asynchronous systems finish applying effects, a microtask dispatches newly ready work after
335
+ their completion promises settle, without waiting for another animation frame. Deltas stay in each
336
+ system's order without merging or dropping them; `deltaBetweenRuns`, remainders and `firstRun` retain
337
+ their existing behavior. While unpaused, sliced systems continue at most once per host update with
338
+ zero extra delta, and release their dependents when the slice queue drains. Conflicting work can still
339
+ build a backlog.
340
+
341
+ During scheduled `shouldRun()` and `update()` calls, `System.gameTime` reports the logical time of the
342
+ queued update. Worker `world.gameTime` uses that captured value, on real workers and the synchronous
343
+ fallback alike. Delayed dispatch therefore does not borrow a later host update's clock. Outside that
344
+ scope, `System.gameTime` returns the live world clock. Custom schedulers can use
345
+ `system.withGameTime(time, () => system.update(delta))` to establish the same scope. Plain systems and
346
+ `addDataToWorld()` implementations should use `this.gameTime` when they need scheduled time; direct
347
+ reads of `this.world.gameTime` still see the host clock. The default scheduler continues using its
348
+ existing `update(delta)` path and live clock.
349
+
350
+ The compiled schedule is reused until membership changes or `scheduler.rebuild(world)` is called.
351
+ Call `rebuild` after changing access declarations, or to inspect direct edits to `world.systems` before
352
+ the next update; direct membership edits are otherwise checked before each dispatch. Queued updates
353
+ retain their original access reservations, even if metadata is rebuilt while a worker is running.
354
+ Additions join future host updates; removed pending updates are skipped when their predecessors finish,
355
+ and removed running systems retain their reservations until completion. With `ConflictScheduler`,
356
+ `world.pause()` or `world.paused = true` stops enqueueing new updates but finishes draining existing
357
+ work without further `world.update()` calls. Queued systems retain their captured deltas and logical
358
+ times; sliced runs continue through timer tasks with zero extra delta, yielding between slices.
359
+ Worker effects publish as their dependencies finish. Draining does not advance either world clock;
360
+ calling `world.update()` while paused still advances only `playerTime`. `world.resume()` allows new
361
+ work to be queued on the next host update. Reload/clear and destroy discard queued updates and
362
+ cancel scheduled microtasks and slice timers. The world prepares shared memory before deferred dispatch and prevents
363
+ new dispatch while clearing or destroyed. Errors from automatic dispatch emit `system-error`; they
364
+ cannot change a result already returned by `world.update()`.
365
+
366
+ Custom asynchronous systems must report `isCurrentlyRunning()` and notify completion through
367
+ `onRunFinished()` when their work and effects finish. Schedulers may implement the optional
368
+ `prepareEffects(context, system)` hook: it captures the dispatched run and returns a handler receiving
369
+ `SchedulerEffects` with `apply()` and `discard()`. The handler must eventually call one of them.
370
+ `beforeClear(context)` lets such schedulers discard pending effects before the world awaits workers.
371
+ The optional `pause(context)` hook is called when the world's paused flag becomes true, including
372
+ direct assignment. `context.paused` indicates whether new host updates are suspended;
373
+ `context.dispatchAllowed` and `prepareForSystemDispatch()` gate deferred dispatch during teardown.
374
+ Schedulers without these hooks retain immediate application and the existing clear path.
375
+
376
+ This scheduler provides component conflict ordering with the existing synchronous update API. It does
377
+ not implement fixed simulation ticks or impose a stable order on unrelated workers' callbacks.
378
+ World clocks continue advancing with host updates. Iterable slicing uses wall-clock budgets;
379
+ entity workers can opt into deterministic work batches instead.
380
+ For a fixed input-update sequence, declared conflicting operations and each system's deltas/logical
381
+ times remain ordered independently of worker completion order. Independent callbacks and timing events
382
+ can arrive in different orders; shared effects must be declared as conflicts or marked exclusive.
383
+ Repeatable simulations also need deterministic inputs and system code, and safe external world edits.
384
+
196
385
  ## Iterating entities
197
386
 
198
387
  `world.entities` is a `Map` keyed by `eid`, not an array, and so is `entities` on `EntitySystem` and
@@ -273,7 +462,37 @@ class DamageSystem extends EntityWorkerSystem<Components, { health: Int32Array }
273
462
  }
274
463
  ```
275
464
 
276
- ### Update-function hooks: `init`, `queryChanged` and `preRun`
465
+ ### Incremental entity work
466
+
467
+ Set `workBatchSize: 1024` in an `EntityWorkerSystem` configuration to process at most
468
+ 1,024 snapshot positions per scheduled update. The size must be a positive safe integer.
469
+ Batch boundaries depend on entity count and insertion order, never elapsed wall time.
470
+ The last batch can be smaller, and removed entities consume their original positions without
471
+ being updated. New members wait for the next pass; current component bundles and named queries
472
+ are refreshed before every batch.
473
+
474
+ `deltaBetweenRuns` starts each full pass. All its batches share the original `elapsedTime`,
475
+ logical `gameTime`, and data from `addDataToWorld()`. Delta accumulated during the pass is
476
+ carried toward the next pass. `preRun` receives the full current entity list once at the
477
+ start, while optional `preBatch` receives only the selected entities before each batch.
478
+ Worker-local fields assigned to `world` survive until the pass ends. `queryChanged` and
479
+ `entityRemoved` still receive each batch's membership changes.
480
+
481
+ Each batch publishes callbacks, creations and component changes independently. Under
482
+ `ConflictScheduler`, its applied completion releases conflicting systems, allowing physics
483
+ or other frequent work between batches. Access declarations must cover both hooks and
484
+ entity updates. This opt-in permits other systems to change shared state between batches;
485
+ use `preBatch` to refresh live indexes and avoid retaining component block references across
486
+ batch boundaries. A failed preparation hook abandons the remaining pass. Clear/reload drops
487
+ the pending snapshot and worker-local pass state.
488
+
489
+ This also works with `DefaultScheduler` and the main-thread fallback. It does not apply to
490
+ `WorkerSystem`, whose function runs once over named queries. Full-query preparation and
491
+ query-delta handling remain unbounded, so a count limit alone cannot guarantee a frame deadline.
492
+ Paused conflict scheduling drains queued batches; unqueued pass work resumes with future
493
+ host updates. `waitForRunToComplete()` waits for the active batch and its publication.
494
+
495
+ ### Update-function hooks: `init`, `queryChanged`, `preRun` and `preBatch`
277
496
 
278
497
  `addDataToWorld` runs on the main thread and re-sends its data every run. When the data instead needs to
279
498
  *live in the worker* — computed once, or too big to ship each frame — attach hooks to the update function
@@ -282,7 +501,11 @@ itself. Both are optional properties on the `EntityUpdateFunction`.
282
501
  **`init`** runs on `system.finishLoading()` — once at startup (`world.init()` calls it), and again on each
283
502
  `world.load()` so a reused world re-seeds its workers. It receives whatever the system's `getInitData()` returned
284
503
  (structured-cloned across the boundary) and returns a `Partial<W>` that is merged onto `world` on every
285
- subsequent run. Use it for state that must be seeded from the main thread but then persist inside the worker
504
+ subsequent run. The same `load` message also carries the system's current query membership, so the worker has
505
+ every loaded entity cached before `finishLoading()` resolves. `init(world, data, entities, queries)` receives those
506
+ populated lists plus a base `world` with `gameTime`, `getString` and `heap` (no `elapsedTime` or callbacks: init
507
+ seeds worker state and must not modify entities); that initial membership never reaches `queryChanged`, so build any index over the loaded entities
508
+ in `init`. Use it for state that must be seeded from the main thread but then persist inside the worker
286
509
  — a seeded RNG, a lookup table, a config object — without paying to re-send it each frame. Type the init
287
510
  data with the `D` type parameter (the fourth on `EntityUpdateFunction` / `EntityWorkerSystem`) so `getInitData`
288
511
  and the `init` hook agree on its shape; it defaults to `unknown` when a system has no init data:
@@ -300,7 +523,7 @@ const damageUpdate: EntityUpdateFunction<Components, { health: Int32Array }, Dam
300
523
 
301
524
  // Runs once in the worker; its return is merged onto `world` before every run. `data` is typed as
302
525
  // `DamageInitData | undefined` (undefined when the system supplies no getInitData).
303
- damageUpdate.init = (data) => ({ damage: data?.baseDamage ?? 1 });
526
+ damageUpdate.init = (_world, data) => ({ damage: data?.baseDamage ?? 1 });
304
527
 
305
528
  class DamageSystem extends EntityWorkerSystem<Components, { health: Int32Array }, DamageWorld, DamageInitData> {
306
529
  constructor(world: BaseWorld<Components>) {
@@ -335,11 +558,13 @@ damageUpdate.preRun = (world, entities, queries, callbacks) => {
335
558
  with that query's name and its `{ added, removed }` delta. Use it to keep a worker-local structure in sync with a
336
559
  set of entities that rarely changes - a navigation grid of static walls, a lookup of static targets - instead of
337
560
  rescanning the whole query every run. `added` can repeat an entity already in the query (its components changed
338
- and its blocks were re-sent), so treat it as an upsert. Keep that structure on the `init`-returned state so a
339
- reloaded world rebuilds it from scratch; the reload re-sends every member as added:
561
+ and its blocks were re-sent), so treat it as an upsert. Seed that structure in `init` from the loaded `queries`,
562
+ since entities present at load are never reported to `queryChanged`; a reload re-runs `init`, rebuilding it:
340
563
 
341
564
  ```ts
342
- wallUpdate.init = () => ({ walls: new Map<number, Rect>() });
565
+ wallUpdate.init = (_world, _data, _entities, queries) => ({
566
+ walls: new Map((queries.walls ?? []).map(({ entityId, components }) => [entityId, rectOf(components.transform)])),
567
+ });
343
568
  wallUpdate.queryChanged = (world, queryName, delta) => {
344
569
  delta.removed.forEach(entityId => world.walls.delete(entityId));
345
570
  delta.added.forEach(({ entityId, components }) => world.walls.set(entityId, rectOf(components.transform)));
@@ -390,9 +615,15 @@ import { createSystemWorker } from '@daneren2005/shared-memory-ecs/worker';
390
615
  createSystemWorker(self, spawnerUpdate, registry);
391
616
  ```
392
617
 
393
- The run function may carry the same `init` / `queryChanged` / `entityRemoved` hooks as an `updateFunction`. Everything else —
618
+ The run function may carry the same `init` / `queryChanged` / `entityRemoved` hooks as an `updateFunction`; its
619
+ `init(world, data, queries)` omits the entity list, since there is no main query. Everything else —
394
620
  callbacks, worker fallback, off-thread creation — behaves exactly as an `EntityWorkerSystem`.
395
621
 
622
+ The worker helpers report unhandled promise rejections on the main thread with the system name,
623
+ preserving the error message and stack. Rejections before the startup handshake completes reject
624
+ `system.init()`; later rejections are logged. Custom worker scopes can provide
625
+ `addEventListener('unhandledrejection', listener)` to enable this reporting.
626
+
396
627
  ### Importing in workers
397
628
 
398
629
  Each worker entry file is bundled on its own, and a single-entry bundle cannot tree-shake this package's
@@ -527,15 +758,15 @@ createEntityWorker(world, { type: 'ship', x: 100, y: 150 }, callbacks);
527
758
  counter (so it never collides with one the main thread or another worker hands out), and for each component
528
759
  the merged config triggers, pushes a block into its pool and writes the values - all off-thread. With entity
529
760
  classes registered, the worker also resolves the template's class and considers only its allowed components. It reports
530
- back an id-plus-block-indexes descriptor; when the run completes the main thread *adopts* it, building the
761
+ back an id-plus-block-indexes descriptor; when completion effects are applied the main thread *adopts* it, building the
531
762
  always-present `entity` component there (interning `type` and `class` is a main-thread job), asking the factory
532
- for the registered wrapper, and attaching each block the worker wrote - no block is copied or re-allocated. Like
533
- every other worker report-back, the new entity first
534
- exists on the following frame, so the system picks it up next run.
763
+ for the registered wrapper, and attaching each block the worker wrote - no block is copied or re-allocated. The new entity joins queries at adoption and is available to subsequent dispatches. The default scheduler
764
+ adopts immediately on completion; the conflict scheduler can defer adoption until publication is safe.
535
765
 
536
766
  Two things make this work, both opt-in so only the workers that create entities pay for them:
537
767
 
538
- - Register the system with `createsEntities: true`. That ships the factory templates to its worker on load.
768
+ - Register the system with `createsEntities: true`, or a component list for parallel conflict scheduling.
769
+ Both forms ship the factory templates to its worker on load.
539
770
  - In that system's worker entry, pass your component registry to `createEntitySystemWorker`, so the worker has
540
771
  each component's block builder:
541
772
 
@@ -683,7 +914,9 @@ from `entity-removed`, which fires for every removal, reloads included).
683
914
 
684
915
  A world is meant to be reused rather than rebuilt. `world.load(config)` swaps in a fresh scenario — it removes
685
916
  the old entities, clears every system, and loads the new batch (deferred frees from the old contents drain over
686
- the following updates, as above). `await world.clear()` instead tears the world all the way back down to an
917
+ the following updates, as above). Loading itself is synchronous, but it returns a promise that resolves once every
918
+ system's `finishLoading()` (e.g. a worker re-seeding its `init` data) completes — `await world.load(config)` before
919
+ starting the game loop. `await world.clear()` instead tears the world all the way back down to an
687
920
  empty, reusable state: it waits for every system's in-flight worker run to finish so nothing is still reading
688
921
  the memory, frees the held blocks immediately, and resolves once the world is ready to load into again.
689
922
 
@@ -693,6 +926,52 @@ through `system.addEntities(batch)`. Entities with the same component keys are g
693
926
  test runs once per group instead of once per entity; system membership keeps load order. A system that overrides
694
927
  `checkAddEntity` (or an `EntityWorkerSystem` that overrides `matchesQuery`) still has it called for each entity.
695
928
 
929
+ ### Compressing saves
930
+
931
+ Big maps are mostly entities whose save is a `type` plus a few integers. `compressEntities(entities, definitions)`
932
+ packs each config that fits a definition into a short ASCII string and leaves every other config as an object:
933
+
934
+ ```ts
935
+ import { compressEntities } from '@daneren2005/shared-memory-ecs';
936
+
937
+ const save = compressEntities(entities, [
938
+ { type: 'grass', properties: [
939
+ // Stored as (value - offset) / step; tile centres become small tile indices.
940
+ { name: 'x', type: 'uint', step: 32, offset: 16 },
941
+ { name: 'y', type: 'uint', step: 32, offset: 16 },
942
+ { name: 'harvestAmount', type: 'uint' },
943
+ { name: 'harvestProgress', type: 'uint' },
944
+ ] },
945
+ ]);
946
+ // { type: 'grass', x: 7920, y: 15440, harvestProgress: 633000 } becomes '#g(q-!ok{)'; a worker stays an object.
947
+ // => { entityDefinitions: { 0: { type: 'grass', properties: [...] } }, entities: ['#g(q-!ok{)', { type: 'worker', ... }] }
948
+ await world.load({ ...save, gameTime });
949
+ ```
950
+
951
+ `world.load()` expands string entities with the bundled `entityDefinitions`, and `decompressEntities` /
952
+ `decompressEntity` do the same for tools. Property types are `int` (signed), `uint`, `boolean`, `float32` and `string`. `int`
953
+ and `uint` decode as `stored * step / 10 ** decimals + offset` (defaults 1, 0, 0), so `decimals: 2` keeps hundredths.
954
+ `float32` takes any number a `Float32Array` holds exactly, such as positions and velocities read from shared memory. A config stays an
955
+ object when it has a property its definition lacks, or a value that wouldn't decode back exactly. Only definitions some
956
+ string uses are bundled, so defining rare types costs nothing until one appears.
957
+
958
+ Types with the same properties can share one definition by listing `types` instead of `type`; the save then stores
959
+ the property list once:
960
+
961
+ ```ts
962
+ { types: ['oak', 'pine', 'birch'], properties: [...] }
963
+ ``` Missing values decode as absent. The definitions travel inside the save, so a later build can append,
964
+ reorder or drop properties without breaking older saves.
965
+
966
+ The string format lets other tools read saves without this library. A string is the definition key, then one field
967
+ per property in definition order. A shared definition saved at key `n` gives its `types` the keys `n`, `n + 1`, ... in
968
+ order, so every type still has its own key and sharing adds nothing to the strings. A field is `!` for absent, or a varint: little-endian base-45 digits, where every
969
+ digit except the last comes from `` PQRSTUVWXYZ[]^_`abcdefghijklmnopqrstuvwxyz{|} `` and the last from
970
+ `#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNO` (digit = position in its alphabet). Fields after the last present
971
+ value are left out. `int` values are zigzag encoded (`n >= 0 ? 2n : -2n - 1`), booleans are 0 or 1, and a `float32` is
972
+ the varint of its 32 IEEE 754 bits. A `string` is a varint length in UTF-16 code units followed by the raw characters,
973
+ which may be any character; length 0 means `null`, and empty strings stay objects.
974
+
696
975
  ## Measuring performance
697
976
 
698
977
  `PerformanceTiming` watches a world and reports what running it costs. Hand it the world and it hooks itself
@@ -714,9 +993,10 @@ Every entry is an `{ avg, min, max, samples }` over the window just closed:
714
993
  - `stats.update` - one whole `world.update` call on the thread the world lives on.
715
994
  - `stats.systems[]` - per system, in run order: `run` is the run itself on its worker, as the worker measured
716
995
  it, and `events` is what handling that run's results (the events it reported onto entities, the entities it
717
- asked to be created) cost back on the calling thread. A system running on the main-thread fallback never
718
- reports either, so it sits at zero with `samples: 0` - which is what tells it apart from one that genuinely
719
- cost nothing.
996
+ asked to be created) cost back on the calling thread. Both worker and main-thread fallback runs report
997
+ these timings. For `workBatchSize`, one sample sums all batches in a complete pass: batches taking 3, 5,
998
+ and 4 ms report a 12 ms run with `samples: 1`. Event handling is summed across the pass too; gaps between
999
+ batches are excluded. Failed preparation closes the attempt with the time spent so far.
720
1000
  - `stats.events` - every system's event handling added together. Workers finish on their own schedule rather
721
1001
  than on a frame boundary, so there is no per-frame combined sample to take; these are the per-system figures
722
1002
  summed at the end of the window, giving what a run of every system costs the main thread between them.
@@ -724,8 +1004,9 @@ Every entry is an `{ avg, min, max, samples }` over the window just closed:
724
1004
  To coordinate reporting with an external clock, construct with `{ autoUpdate: false }` and call
725
1005
  `timing.flush()` at each reporting boundary. It publishes and returns a snapshot of all collected update,
726
1006
  worker, and event samples, then starts a fresh window. This also works while paused: windows without work
727
- report zeroes. Worker results belong to the window in which they arrive. Automatic reporting remains
728
- enabled by default.
1007
+ report zeroes. A batched pass retains its partial totals across reporting boundaries and contributes
1008
+ one sample when its final batch arrives (event timing completes when its results are handled). Clear/reload
1009
+ drops unfinished passes, and the next pass starts fresh totals. Automatic reporting remains enabled by default.
729
1010
 
730
1011
  `getSystemStats(name)` pulls one system out of the latest snapshot, `reset()` throws away everything collected
731
1012
  so far (worth doing after loading a new scene, when the samples either side are not comparable), and