@driftengine/entities 3.61.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.
@@ -0,0 +1,35 @@
1
+ /*! DriftEngine | Copyright 2026 Drift Technologies | Apache-2.0 | https://github.com/drftrun/driftengine */
2
+ /**
3
+ * The entity model's public surface.
4
+ *
5
+ * **This package imports no other engine package**, which is a stronger rule than the one every
6
+ * package obeys and is the reason it can be depended on by anything. An entity model that knew what
7
+ * a `Transform` was would be an entity model that knew it had a scene — and the whole argument for
8
+ * declared reads and writes is that a declaration is about a component *id* rather than about what
9
+ * the component means.
10
+ *
11
+ * It declares no component types at all, for the same reason. A consumer writes the ones its world
12
+ * needs, or takes them from whichever package owns the subsystem.
13
+ *
14
+ * The one dependency is `driftscript`, for `Schema` and `migrate` — 406 bytes gzipped, measured.
15
+ * That is the design's spine rather than a convenience: storage layout, serialization, migration
16
+ * and a future inspector all read **one** description of a component, so there is no second one to
17
+ * keep in sync.
18
+ */
19
+ export type { ComponentStoreOptions, ComponentType, StoreSnapshot } from './store.ts';
20
+ export { ComponentStore, createStoreSnapshot, defineComponent } from './store.ts';
21
+ export type { ComponentView, ViewColumn } from './view.ts';
22
+ export type { CursorHost } from './query.ts';
23
+ export { QueryCursor } from './query.ts';
24
+ export type { WorldSnapshot } from './world.ts';
25
+ export { World, createWorldSnapshot } from './world.ts';
26
+ export type { SystemDefinition, SystemView } from './system.ts';
27
+ export { BoundSystem } from './system.ts';
28
+ export type { Schedule } from './schedule.ts';
29
+ export { buildSchedule, runSchedule } from './schedule.ts';
30
+ export type { Prefab } from './prefab.ts';
31
+ export { definePrefab, instantiate } from './prefab.ts';
32
+ export type { LoadResult, SerializedScene } from './scene.ts';
33
+ export { deserializeWorld, serializeWorld } from './scene.ts';
34
+ export type { AllocatorSnapshot, Entity, EntityAllocatorOptions } from './entity.ts';
35
+ export { ENTITY_GENERATION_CEILING, ENTITY_INDEX_CEILING, EntityAllocator, createAllocatorSnapshot, entityGeneration, entityIndex, packEntity, } from './entity.ts';
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ /*! DriftEngine | Copyright 2026 Drift Technologies | Apache-2.0 | https://github.com/drftrun/driftengine */
2
+ export { ComponentStore, createStoreSnapshot, defineComponent } from './store.js';
3
+ export { QueryCursor } from './query.js';
4
+ export { World, createWorldSnapshot } from './world.js';
5
+ export { BoundSystem } from './system.js';
6
+ export { buildSchedule, runSchedule } from './schedule.js';
7
+ export { definePrefab, instantiate } from './prefab.js';
8
+ export { deserializeWorld, serializeWorld } from './scene.js';
9
+ export { ENTITY_GENERATION_CEILING, ENTITY_INDEX_CEILING, EntityAllocator, createAllocatorSnapshot, entityGeneration, entityIndex, packEntity, } from './entity.js';
@@ -0,0 +1,31 @@
1
+ /**
2
+ * A prefab is a **value**, and instantiating it writes that value into a fresh entity.
3
+ *
4
+ * **There is no link back**, and that is a decision rather than a limitation. A live link is what an
5
+ * *editor* wants — change the prefab and every instance changes — and it costs every instance a
6
+ * record of which fields it has overridden, checked on every write. Track K is what needs that, and
7
+ * it can add the record without changing what a prefab is.
8
+ *
9
+ * Overrides are applied at instantiation and are not remembered for the same reason: an override
10
+ * that was remembered would be half of that record, doing none of its job.
11
+ */
12
+ import type { Entity } from './entity.ts';
13
+ import type { ComponentType } from './store.ts';
14
+ import type { World } from './world.ts';
15
+ export interface Prefab {
16
+ readonly name: string;
17
+ readonly components: readonly (readonly [ComponentType, Readonly<Record<string, unknown>>])[];
18
+ }
19
+ export declare function definePrefab(name: string, components: readonly (readonly [ComponentType, Readonly<Record<string, unknown>>])[]): Prefab;
20
+ /**
21
+ * Make one.
22
+ *
23
+ * Overrides are keyed by component **name**, because that is what a caller has in front of them —
24
+ * a `ComponentType` key would mean holding the type to override a field of it, which is exactly the
25
+ * import a scene file or an editor panel does not have.
26
+ *
27
+ * An override naming a component the prefab does not carry is **refused**. Adding it instead would
28
+ * make a typo a silent extra component, and adding nothing would make a typo a silent no-op; the
29
+ * only answer that cannot be mistaken for working is to say so.
30
+ */
31
+ export declare function instantiate(world: World, prefab: Prefab, overrides?: Readonly<Record<string, Readonly<Record<string, unknown>>>>): Entity;
package/dist/prefab.js ADDED
@@ -0,0 +1,47 @@
1
+ export function definePrefab(name, components) {
2
+ return { name, components };
3
+ }
4
+ /**
5
+ * Make one.
6
+ *
7
+ * Overrides are keyed by component **name**, because that is what a caller has in front of them —
8
+ * a `ComponentType` key would mean holding the type to override a field of it, which is exactly the
9
+ * import a scene file or an editor panel does not have.
10
+ *
11
+ * An override naming a component the prefab does not carry is **refused**. Adding it instead would
12
+ * make a typo a silent extra component, and adding nothing would make a typo a silent no-op; the
13
+ * only answer that cannot be mistaken for working is to say so.
14
+ */
15
+ export function instantiate(world, prefab, overrides = {}) {
16
+ const named = new Set(prefab.components.map(([type]) => type.name));
17
+ for (const name of Object.keys(overrides)) {
18
+ if (named.has(name))
19
+ continue;
20
+ throw new Error(`\`${prefab.name}\` has no \`${name}\` to override. It has ${[...named].join(', ')}. An ` +
21
+ 'override for something absent is a typo either way — added silently, or ignored silently.');
22
+ }
23
+ const entity = world.create();
24
+ for (const [type, values] of prefab.components) {
25
+ const override = overrides[type.name];
26
+ /*
27
+ * **Spread would apply a key whose value is `undefined`, which is not an override.** A caller
28
+ * building one from optional data — `{ hp: config.hp }` where `config.hp` may be absent — means
29
+ * "leave it alone", and a spread means "set it to nothing": the component is then written with
30
+ * `undefined` where a number belongs. Measured elsewhere in this repository as the same shape
31
+ * taking a camera's arm to `NaN` on its first step. Nothing here can see it, because this
32
+ * repository does not run `exactOptionalPropertyTypes`.
33
+ */
34
+ world.add(entity, type, override === undefined ? values : applyOverride(values, override));
35
+ }
36
+ return entity;
37
+ }
38
+ /** `values` with every *defined* key of `override` applied over it. */
39
+ function applyOverride(values, override) {
40
+ const merged = { ...values };
41
+ for (const key of Object.keys(override)) {
42
+ const value = override[key];
43
+ if (value !== undefined)
44
+ merged[key] = value;
45
+ }
46
+ return merged;
47
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Iterating the entities that have a set of components, without allocating.
3
+ *
4
+ * **An iterator allocated per frame is the per-frame allocation `AGENTS.md` forbids**, arriving
5
+ * through the most-used call in the package. So a cursor is its own iterator, returns a **reused**
6
+ * result record, and comes from a pool the world owns — the same shape the render graph's arena
7
+ * uses, and for the same reason.
8
+ *
9
+ * The cost is that **a query result may not outlive the loop it was made in**. A caller that stores
10
+ * one gets whatever the next query writes into it. That is stated here rather than discovered, and
11
+ * it is the bargain the arena makes too.
12
+ *
13
+ * ---
14
+ *
15
+ * ## The walk is over the smallest set
16
+ *
17
+ * A query over three components walks one dense array and tests membership in the other two, so the
18
+ * work is bounded by the *smallest* of them. Walking the first named instead would make
19
+ * `query(Position, Dying)` cost the whole world when two entities are dying.
20
+ *
21
+ * **The order is not sorted and is not promised.** It follows the chosen dense array, whose order is
22
+ * insertion modified by swap-removal — deterministic given the same history, which is what a replay
23
+ * needs, and not a stable property of the set. A consumer needing a stable order sorts by entity,
24
+ * and the fact that it is a choice is why this says so.
25
+ */
26
+ import type { Entity } from './entity.ts';
27
+ import type { ComponentStore, ComponentType } from './store.ts';
28
+ /**
29
+ * What a cursor needs from the world it belongs to.
30
+ *
31
+ * An interface rather than the `World` class, because a cursor importing the world and the world
32
+ * importing the cursor is a cycle — and because what a cursor actually needs is two functions.
33
+ */
34
+ export interface CursorHost {
35
+ store(type: ComponentType): ComponentStore;
36
+ releaseCursor(cursor: QueryCursor): void;
37
+ }
38
+ export declare class QueryCursor implements Iterator<Entity>, Iterable<Entity> {
39
+ /**
40
+ * Stores every yielded entity must be in, including the one being walked.
41
+ *
42
+ * **Reused with a count rather than emptied**, and that is not a micro-optimisation. Setting
43
+ * `length = 0` and pushing again makes V8 re-grow the backing store, which allocates — measured:
44
+ * half a million `length = 0` and two pushes collected as often as a control allocating two
45
+ * objects per iteration, while writing in place collected nothing. The whole point of a pooled
46
+ * cursor is undone by emptying its arrays.
47
+ */
48
+ private readonly required;
49
+ private requiredCount;
50
+ /** Stores no yielded entity may be in. Same arrangement, same reason. */
51
+ private readonly excluded;
52
+ private excludedCount;
53
+ private walking;
54
+ private at;
55
+ private readonly step;
56
+ private host;
57
+ private open_;
58
+ constructor(host: CursorHost);
59
+ private readonly owner;
60
+ /**
61
+ * Configure and take ownership.
62
+ *
63
+ * **Four component types rather than a rest parameter**, because `...types` allocates an array on
64
+ * every call — once per system per frame, which is exactly the hot path this file exists for.
65
+ * Four covers every query in the corpus; a fifth is `.with()`, which is one more call and no
66
+ * array either.
67
+ */
68
+ open(a: ComponentType, b?: ComponentType, c?: ComponentType, d?: ComponentType): this;
69
+ /** Require a component without yielding anything about it. */
70
+ with(type: ComponentType): this;
71
+ /** Exclude everything that has this component. */
72
+ without(type: ComponentType): this;
73
+ private require;
74
+ [Symbol.iterator](): Iterator<Entity>;
75
+ next(): IteratorResult<Entity>;
76
+ /** Called by `for…of` when a loop breaks, which is what stops an abandoned cursor leaking. */
77
+ return(): IteratorResult<Entity>;
78
+ private matches;
79
+ private chooseSmallest;
80
+ private finish;
81
+ }
package/dist/query.js ADDED
@@ -0,0 +1,140 @@
1
+ export class QueryCursor {
2
+ /**
3
+ * Stores every yielded entity must be in, including the one being walked.
4
+ *
5
+ * **Reused with a count rather than emptied**, and that is not a micro-optimisation. Setting
6
+ * `length = 0` and pushing again makes V8 re-grow the backing store, which allocates — measured:
7
+ * half a million `length = 0` and two pushes collected as often as a control allocating two
8
+ * objects per iteration, while writing in place collected nothing. The whole point of a pooled
9
+ * cursor is undone by emptying its arrays.
10
+ */
11
+ required = [];
12
+ requiredCount = 0;
13
+ /** Stores no yielded entity may be in. Same arrangement, same reason. */
14
+ excluded = [];
15
+ excludedCount = 0;
16
+ walking = null;
17
+ at = 0;
18
+ step = { value: 0, done: false };
19
+ host = null;
20
+ open_ = false;
21
+ constructor(host) {
22
+ this.host = host;
23
+ this.owner = host;
24
+ }
25
+ owner;
26
+ /**
27
+ * Configure and take ownership.
28
+ *
29
+ * **Four component types rather than a rest parameter**, because `...types` allocates an array on
30
+ * every call — once per system per frame, which is exactly the hot path this file exists for.
31
+ * Four covers every query in the corpus; a fifth is `.with()`, which is one more call and no
32
+ * array either.
33
+ */
34
+ open(a, b, c, d) {
35
+ this.requiredCount = 0;
36
+ this.excludedCount = 0;
37
+ this.require(a);
38
+ if (b !== undefined)
39
+ this.require(b);
40
+ if (c !== undefined)
41
+ this.require(c);
42
+ if (d !== undefined)
43
+ this.require(d);
44
+ this.walking = null;
45
+ this.at = 0;
46
+ this.host = this.owner;
47
+ this.open_ = true;
48
+ return this;
49
+ }
50
+ /** Require a component without yielding anything about it. */
51
+ with(type) {
52
+ this.require(type);
53
+ return this;
54
+ }
55
+ /** Exclude everything that has this component. */
56
+ without(type) {
57
+ const store = this.owner.store(type);
58
+ if (this.excludedCount < this.excluded.length)
59
+ this.excluded[this.excludedCount] = store;
60
+ else
61
+ this.excluded.push(store);
62
+ this.excludedCount += 1;
63
+ return this;
64
+ }
65
+ require(type) {
66
+ const store = this.owner.store(type);
67
+ /* Written in place where the slot exists; pushed only the first time the cursor is asked for a
68
+ query this wide, so a pool of cursors settles at the widest query a consumer makes. */
69
+ if (this.requiredCount < this.required.length)
70
+ this.required[this.requiredCount] = store;
71
+ else
72
+ this.required.push(store);
73
+ this.requiredCount += 1;
74
+ }
75
+ [Symbol.iterator]() {
76
+ /*
77
+ * The cursor is its own iterator. Returning a fresh object here would allocate one per loop,
78
+ * which is the whole thing this file exists to avoid — and it is what makes `for…of` over a
79
+ * cursor twice at once wrong, which the pool is what stops.
80
+ */
81
+ return this;
82
+ }
83
+ next() {
84
+ if (!this.open_)
85
+ return this.step.done ? this.step : this.finish();
86
+ if (this.walking === null)
87
+ this.chooseSmallest();
88
+ const walking = this.walking;
89
+ if (walking === null)
90
+ return this.finish();
91
+ const dense = walking.dense;
92
+ for (; this.at < walking.size; this.at += 1) {
93
+ const entity = dense[this.at];
94
+ if (!this.matches(entity, walking))
95
+ continue;
96
+ this.at += 1;
97
+ this.step.value = entity;
98
+ this.step.done = false;
99
+ return this.step;
100
+ }
101
+ return this.finish();
102
+ }
103
+ /** Called by `for…of` when a loop breaks, which is what stops an abandoned cursor leaking. */
104
+ return() {
105
+ return this.finish();
106
+ }
107
+ matches(entity, walking) {
108
+ for (let i = 0; i < this.requiredCount; i += 1) {
109
+ const store = this.required[i];
110
+ if (store !== walking && !store.has(entity))
111
+ return false;
112
+ }
113
+ for (let i = 0; i < this.excludedCount; i += 1) {
114
+ if (this.excluded[i].has(entity))
115
+ return false;
116
+ }
117
+ return true;
118
+ }
119
+ chooseSmallest() {
120
+ let smallest = null;
121
+ for (let i = 0; i < this.requiredCount; i += 1) {
122
+ const store = this.required[i];
123
+ if (smallest === null || store.size < smallest.size)
124
+ smallest = store;
125
+ }
126
+ this.walking = smallest;
127
+ }
128
+ finish() {
129
+ this.walking = null;
130
+ if (this.open_) {
131
+ this.open_ = false;
132
+ /* Through the host rather than a closure handed in at `open`: a closure per query is an
133
+ allocation per system per frame, which is the thing this file refuses. */
134
+ this.owner.releaseCursor(this);
135
+ }
136
+ this.step.value = 0;
137
+ this.step.done = true;
138
+ return this.step;
139
+ }
140
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * A world as data: entities, their components, and every value under a **stable field id**.
3
+ *
4
+ * Ids rather than names, so a field renamed with an explicit id still loads; never positions,
5
+ * because a field inserted in the middle would renumber every field after it and a save would load
6
+ * the right names with the wrong values — worse than failing, because it looks like it worked.
7
+ *
8
+ * ---
9
+ *
10
+ * ## Loading across a change goes through `migrate`, unchanged
11
+ *
12
+ * The file carries the **schema each component had when it was written**, so a load has both sides
13
+ * and can hand them to the migration Track N's Phase 5 built. A field added since arrives with its
14
+ * default, one removed is dropped, and one whose *type* changed is refused naming it rather than
15
+ * coerced. Nothing here re-implements any of that; if the migration needs changing, the change
16
+ * belongs in `driftscript` and serves both callers.
17
+ *
18
+ * ## Entity references are remapped, which is why the type has to be declared
19
+ *
20
+ * A saved entity's slot may be live in the world being loaded into, so a scene that kept its
21
+ * handles would overwrite whatever held them. Every entity is created fresh and every stored
22
+ * reference is rewritten through the map — which only works for fields **declared** `Entity`. A
23
+ * number that happens to hold one is a number, and is left alone. That is the cost of not guessing.
24
+ *
25
+ * ## It writes nothing until it can write everything
26
+ *
27
+ * A refusal on the fifth entity must not leave four in the world. Every migration runs first, into
28
+ * plain objects; the world is touched only once all of them have succeeded.
29
+ */
30
+ import { type Schema } from 'driftscript';
31
+ import type { Entity } from './entity.ts';
32
+ import type { ComponentType } from './store.ts';
33
+ import type { World } from './world.ts';
34
+ export interface SerializedScene {
35
+ readonly version: 1;
36
+ /** What each component type looked like when this was written. The `from` half of a migration. */
37
+ readonly schemas: Readonly<Record<string, Schema>>;
38
+ readonly entities: readonly {
39
+ /** Component name → field id → value. Entity-typed values hold a scene index, not a handle. */
40
+ readonly components: Readonly<Record<string, Readonly<Record<string, unknown>>>>;
41
+ }[];
42
+ }
43
+ export type LoadResult = {
44
+ readonly loaded: true;
45
+ readonly entities: readonly Entity[];
46
+ } | {
47
+ readonly loaded: false;
48
+ readonly reason: string;
49
+ };
50
+ /**
51
+ * Write a world out.
52
+ *
53
+ * The component types are passed rather than discovered, because a world holds a store for every
54
+ * type anything has ever asked it about — including empty ones a query created — and a scene is a
55
+ * decision about what to save rather than everything that happens to exist.
56
+ */
57
+ export declare function serializeWorld(world: World, types: readonly ComponentType[]): SerializedScene;
58
+ /** Read a world in, or refuse in words having written nothing. */
59
+ export declare function deserializeWorld(world: World, scene: SerializedScene, types: readonly ComponentType[]): LoadResult;
package/dist/scene.js ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * A world as data: entities, their components, and every value under a **stable field id**.
3
+ *
4
+ * Ids rather than names, so a field renamed with an explicit id still loads; never positions,
5
+ * because a field inserted in the middle would renumber every field after it and a save would load
6
+ * the right names with the wrong values — worse than failing, because it looks like it worked.
7
+ *
8
+ * ---
9
+ *
10
+ * ## Loading across a change goes through `migrate`, unchanged
11
+ *
12
+ * The file carries the **schema each component had when it was written**, so a load has both sides
13
+ * and can hand them to the migration Track N's Phase 5 built. A field added since arrives with its
14
+ * default, one removed is dropped, and one whose *type* changed is refused naming it rather than
15
+ * coerced. Nothing here re-implements any of that; if the migration needs changing, the change
16
+ * belongs in `driftscript` and serves both callers.
17
+ *
18
+ * ## Entity references are remapped, which is why the type has to be declared
19
+ *
20
+ * A saved entity's slot may be live in the world being loaded into, so a scene that kept its
21
+ * handles would overwrite whatever held them. Every entity is created fresh and every stored
22
+ * reference is rewritten through the map — which only works for fields **declared** `Entity`. A
23
+ * number that happens to hold one is a number, and is left alone. That is the cost of not guessing.
24
+ *
25
+ * ## It writes nothing until it can write everything
26
+ *
27
+ * A refusal on the fifth entity must not leave four in the world. Every migration runs first, into
28
+ * plain objects; the world is touched only once all of them have succeeded.
29
+ */
30
+ import { migrate } from 'driftscript';
31
+ /**
32
+ * Write a world out.
33
+ *
34
+ * The component types are passed rather than discovered, because a world holds a store for every
35
+ * type anything has ever asked it about — including empty ones a query created — and a scene is a
36
+ * decision about what to save rather than everything that happens to exist.
37
+ */
38
+ export function serializeWorld(world, types) {
39
+ /* Scene index per entity, so a stored reference is a position in this file rather than a handle
40
+ that means nothing in another world. */
41
+ /*
42
+ * Membership through a `Set`, not `Array.includes`.
43
+ *
44
+ * It was `includes`, which is a linear scan per entity and made this quadratic: **8.8 seconds**
45
+ * for a hundred thousand entities across three components, measured. With a set it is a fraction
46
+ * of that. The shape is worth naming because it reads as obviously correct — the list is the
47
+ * thing being built, so testing it is the natural move — and the cost is invisible until the
48
+ * world is large.
49
+ */
50
+ const indexOf = new Map();
51
+ const living = [];
52
+ for (const type of types) {
53
+ const store = world.store(type);
54
+ for (let i = 0; i < store.size; i += 1) {
55
+ const entity = store.dense[i];
56
+ if (indexOf.has(entity))
57
+ continue;
58
+ indexOf.set(entity, living.length);
59
+ living.push(entity);
60
+ }
61
+ }
62
+ const schemas = {};
63
+ for (const type of types)
64
+ schemas[type.name] = type.schema;
65
+ const entities = living.map((entity) => {
66
+ const components = {};
67
+ for (const type of types) {
68
+ if (!world.has(entity, type))
69
+ continue;
70
+ const fields = {};
71
+ for (const field of type.schema.fields) {
72
+ const value = world.read(entity, type, field.name);
73
+ fields[field.id] = field.type === 'Entity' ? (indexOf.get(value) ?? -1) : value;
74
+ }
75
+ components[type.name] = fields;
76
+ }
77
+ return { components };
78
+ });
79
+ return { version: 1, schemas, entities };
80
+ }
81
+ /** Read a world in, or refuse in words having written nothing. */
82
+ export function deserializeWorld(world, scene, types) {
83
+ const byName = new Map(types.map((type) => [type.name, type]));
84
+ /* Everything checked and migrated before the world is touched at all. */
85
+ const planned = [];
86
+ for (const record of scene.entities) {
87
+ const components = [];
88
+ for (const [name, saved] of Object.entries(record.components)) {
89
+ const type = byName.get(name);
90
+ if (type === undefined) {
91
+ return {
92
+ loaded: false,
93
+ reason: `this scene holds a \`${name}\`, and no component type by that name was supplied. ` +
94
+ 'Loading it as nothing would drop data the file still holds.',
95
+ };
96
+ }
97
+ const from = scene.schemas[name];
98
+ if (from === undefined) {
99
+ return {
100
+ loaded: false,
101
+ reason: `this scene holds a \`${name}\` and no schema for it, so there is nothing to match ` +
102
+ 'its fields by. A scene written without one cannot be loaded across any change at all.',
103
+ };
104
+ }
105
+ /* `migrate` takes an instance keyed by the *saved* names, so the ids in the file are read
106
+ through the saved schema. That is what keeps the file addressed by id and the migration
107
+ unchanged from the one the language uses. */
108
+ const instance = {};
109
+ for (const field of from.fields)
110
+ instance[field.name] = saved[field.id];
111
+ const defaults = {};
112
+ for (const field of type.schema.fields)
113
+ defaults[field.name] = field.type === 'String' ? null : 0;
114
+ const result = migrate(instance, from, type.schema, defaults);
115
+ if (!result.migrated)
116
+ return { loaded: false, reason: result.reason };
117
+ components.push({ type, values: result.value });
118
+ }
119
+ planned.push({ components });
120
+ }
121
+ const made = planned.map(() => world.create());
122
+ planned.forEach((record, index) => {
123
+ const entity = made[index];
124
+ for (const { type, values } of record.components) {
125
+ /* Scene indices become handles here, and only for fields the schema declares `Entity`. */
126
+ for (const field of type.schema.fields) {
127
+ if (field.type !== 'Entity')
128
+ continue;
129
+ const at = values[field.name];
130
+ values[field.name] = typeof at === 'number' && at >= 0 && at < made.length ? made[at] : 0;
131
+ }
132
+ world.add(entity, type, values);
133
+ }
134
+ });
135
+ return { loaded: true, entities: made };
136
+ }
@@ -0,0 +1,44 @@
1
+ import { type SystemDefinition } from './system.ts';
2
+ import type { World } from './world.ts';
3
+ export interface Schedule {
4
+ /** The systems, in the order they run. */
5
+ readonly order: readonly SystemDefinition[];
6
+ /**
7
+ * Runs of adjacent systems that touch nothing in common.
8
+ *
9
+ * Reported rather than used. A group of one is a system that conflicts with its neighbour.
10
+ */
11
+ readonly groups: readonly (readonly SystemDefinition[])[];
12
+ }
13
+ /**
14
+ * Order the systems, refusing what cannot be ordered.
15
+ *
16
+ * Deterministic: the same definitions in the same order produce the same schedule on every machine
17
+ * and every run, which is what a replay of a recorded session depends on.
18
+ */
19
+ export declare function buildSchedule(systems: readonly SystemDefinition[]): Schedule;
20
+ /**
21
+ * Run one fixed step of a schedule.
22
+ *
23
+ * `tick` is the simulation's step count and is what `everyTicks` strides over — so a system at
24
+ * stride 60 runs on tick 0, 60, 120, in every replay of the same input.
25
+ *
26
+ * **A system that throws does not take the rest of the tick with it, since 2026-08-28.** It used
27
+ * to: the throw left this function, so every system scheduled after the failing one never ran —
28
+ * once per tick, for as long as the game was up. Reported from outside, and the way it was found is
29
+ * the argument for containing it. A system queried a component it had not declared, which throws by
30
+ * design; the system that stopped was three places later and was the one stepping a vehicle; the
31
+ * symptom was *a car moving in jerks*, and the only evidence was a console line no headless check
32
+ * reads. A broken system must not be able to silently starve its neighbours.
33
+ *
34
+ * So each system's failure is caught, reported **once** naming the system and the reason, and the
35
+ * schedule carries on. Nothing is disabled: a declaration error will throw again next tick and be
36
+ * caught again, which costs a throw per tick and keeps a transient failure recoverable — and this
37
+ * function no longer throws, which is what `AGENTS.md` requires of anything the frame loop calls.
38
+ *
39
+ * **What it gives up** is the stack reaching a debugger's uncaught-exception break, which is how
40
+ * somebody working on a system would rather meet it. **What would change it** is a consumer wanting
41
+ * failures fatal in development; the answer then is a reporter this takes as a parameter rather than
42
+ * a rule chosen here for everybody.
43
+ */
44
+ export declare function runSchedule(world: World, schedule: Schedule, tick: number): void;