Skip to content

Status: approved

ecs-serialize

Summary

@workspace/ecs-serialize v0 saves a koota world to a JSON snapshot and restores that snapshot into a fresh world, in the spirit of flecs ecs_world_to_json / ecs_world_from_json. koota traits and relations have no names and no per-entity trait list, and entity ids cannot be chosen at spawn, so the package works within the limits listed below. Apps register a stable string name for each trait and relation they want saved (registerTraits, per world). saveWorld(world) writes entities, tag and data trait values, relation pairs (with pair data), and world-level traits. Entity ids are replaced by snapshot indices, and restoreWorld(world, snapshot) spawns new entities and remaps every relation target and every entity reference inside trait values (declared per trait with refs, or converted by a per-trait codec). Values that are not JSON (AoS traits holding Maps, class instances) need a codec. Every failure is a SerializeError with a code, and a failed restore leaves the world unchanged. The driving demo is apps/demo-dnd (brief DND-F08, M2: save a fight in progress and resume it after reload): combatants, HP, HasEffect(Poisoned) pairs with remaining duration, intent and targeting references, cues, the combat log, and the encounter state. v0 has no binary format, no prefab or schedule support, and no migrations.

References

  • flecs (prior art only; nothing is ported literally):
  • FlecsRemoteApi.md § JSON serialization: ecs_world_to_json / ecs_world_from_json, and the same for single entities. The world is saved as a JSON document and read back into a world.
  • EntitiesComponents.md § Names, § Runtime Type Registration: flecs saves components by name and needs type reflection (ecs_struct) to know their fields. Here the registry gives the names, and the SoA schema is the reflection. AoS traits are the flecs opaque types, and need a codec.
  • reference/flecs/test/meta/src/DeserializeFromJson.c: deser_entity_1_pair, deser_entity_1_pair_2_targets, deser_entity_2_pairs (pairs and several targets round trip); deser_unknown_component_* and *_strict (unknown components are skipped by default and an error with strict; this package is always strict, OQ8); deser_unknown_member (unknown field, same idea, SER-075).
  • reference/flecs/test/meta/src/OpaqueTypes.c: ser_*_type_to_json, deser_*_from_json (custom to/from JSON for types the reflection cannot describe; the model for codec).
  • flecs FAQ, "Can Flecs serialize components?": reflection based, as above.
  • bitECS: Serialization.md: "mapping between different entity IDs during deserialization", the same problem as SER-050 and SER-054 (bitECS also offers binary formats, which are out of scope here).
  • koota (public API only, read from reference/koota/packages/core/src):
  • createWorld(), world.spawn(), world.entities, world.has(entity), world.traits (Set<Trait>), world.reset(), world.destroy().
  • entity.has, entity.get, entity.add, entity.set, entity.targetsFor(relation), entity.id(), entity.destroy(); unpackEntity(entity) (exported; entities are packed numbers of world id, generation, and id).
  • World-level traits: world.add, world.get, world.has, world.set, world.remove (they act on the hidden world entity).
  • trait.schema, trait.id; Relation objects branded with the exported $relation; relation({ store }); entity.get(relation(target)); entity.add(relation(target, data)).
  • IsExcluded (exported tag): skipped by every query; carried by the hidden world entity and by ecs-prefab prefabs.
  • ecs-devtools spec: per-world WeakMap name registry (DEVTOOLS-001..004), the L1..L11 limits, and canary tests. This spec reuses its conventions and its findings; the registries stay separate (OQ10).
  • ecs-schedule spec: SCH-063 keeps schedule state in a world trait with serializable fields. It is saved like any other registered world trait; the package has no schedule support.
  • ecs-prefab spec: prefab serialization is deferred to this package, and v0 does not take it up (OQ11).
  • demo-dnd spec: DND-033/034 (Rng, restartEncounter with world.reset()), DND-052/053/089 (observers registered by setup), DND-100 (name registration), and the ECS model's Intent, Targeting, CombatLog, Cues, Rng (entity references and non-JSON values).
  • concept §4, §6, package-rules, testing.

koota public-API limits

The package uses only koota's public exports and works within these limits. Items marked (source) were read from koota's source and are not confirmed at runtime. Each has a canary test, so a koota change fails a test instead of corrupting snapshots.

  • K1 No per-entity trait list. There is no entity.traits(). The traits of an entity are found by looping the registered traits and calling entity.has(trait), but only for traits that are in world.traits (a trait enters world.traits on first use in the world, and is never listed before; see ecs-devtools L3). Saving therefore never calls has or get on a trait the world has not seen (SER-021).
  • K2 Relation pairs. The only public read is entity.targetsFor(relation) for a relation the caller already has. The registry therefore must know every relation to save. A relation's store data is read with entity.get(relation(target)).
  • K3 Relation-backing traits are not identifiable. The trait behind a relation is in world.traits and cannot be told from a plain trait through the public API (it hides behind $internal; ecs-devtools L5). saveWorld has to flag traits it does not know (SER-018), so it needs the set of backing traits of the registered relations. The package finds each backing trait once, at registration, in a scratch world made with createWorld(): spawn two entities, add a pair, and see which trait the source gains. koota traits are module-level objects shared by all worlds (source), so the result holds for every world. The user's world is never touched (SER-006). World ids are limited (4 bits, 16 worlds at a time), so the scratch world is destroyed immediately. Unverified; canary SER-006.
  • K4 Entity ids cannot be chosen. world.spawn() allocates; an Entity is a packed number of world id, generation, and id (unpackEntity). Ids of a saved world are meaningless in another world, so snapshots use indices into the entities array, and restore remaps (SER-050, SER-030).
  • K5 The world entity and excluded entities. world.entities includes the hidden world entity, which carries IsExcluded (source; ecs-devtools L1 and its canary DEVTOOLS-013). Prefabs carry it too. saveWorld never saves an entity that has IsExcluded; the world entity's traits are saved as the world record (SER-014). Canary SER-023.
  • K6 Trait kinds and values. A trait is a tag if trait.schema is an empty object, AoS if it is a function, otherwise SoA. entity.get returns a fresh object for SoA and the stored reference for AoS. Whether add([AosTrait, value]) stores value as given or passes it to the factory is unverified; restore must end with the value stored, however it is done (SER-063, canary).
  • K7 world.reset(). It destroys every entity, clears world.traits, drops queries and tracking, and recreates the entity index so ids restart (source: world.ts). Whether observers registered with onAdd, onRemove, onChange, or onQueryAdd survive is unverified, and apps must assume they do not (ecs-devtools L9; DND-053). Whether an Entity obtained before a reset can alias a new entity after ids restart is unverified. Restore therefore targets a fresh or reset world, and apps drop old handles (SER-055, SER-060, OQ7).
  • K8 Observers fire during restore. world.spawn, add, and pair adds fire onAdd and onQueryAdd observers as they do for any entity, per trait in call order. Restore does not suppress them (SER-057). Apps register observers after restore.
  • K9 Fresh ids ascend (source, unverified). In a fresh or reset world, successive spawn() calls return entities with ascending entity.id(). SER-054 relies on it for the round-trip equality, and its canary fails if koota changes.
  • K10 Pair order (unverified). targetsFor(relation) order equals the order in which the pairs were added. SER-052 relies on it.
  • K11 Not readable publicly: relation options (exclusive, autoDestroy) and the ordered-relation experiment (ordered, OrderedList). The package does not save or restore them; restore only adds pairs, and koota applies the options itself.

Requirements

All requirements are [ecs] (headless package behaviour) unless tagged [ux] (what a developer reading the docs or the package files sees). All are [test] (headless Vitest, fresh koota world per test, no koota mocks) unless tagged [manual]. Groups are sized so that Triage can create one feature item per group: F01 S, F02 M, F03 M, F04 M, F05 M, F06 S, F07 S (test-only, driven by the demo). New IDs are numbered within the group's range.

Terms:

  • A trait or relation is registered if the app passed it to registerTraits for this world, ignored if it was passed to ignoreTraits.
  • A saved entity is an alive entity in world.entities that does not have IsExcluded. The world record describes the traits of the hidden world entity.
  • The kind of a trait is tag, soa, or aos (K6). A data trait is soa or aos.
  • A snapshot index is the position of a record in snapshot.entities. A reference in a snapshot is always an index.
  • A value is JSON-safe if it is null, a boolean, a finite number, a string, an array of JSON-safe values, or a plain object (prototype Object.prototype or null) whose own enumerable string keys hold JSON-safe values. undefined, functions, symbols, bigints, NaN, Infinity, Map, Set, class instances, and cycles are not JSON-safe.
  • Fresh world: a world without any saved entity (SER-055), for example one from createWorld() or after world.reset().

SER-F01 Trait registry (S)

  • SER-001 [ecs][test]: registerTraits(world, entries) MUST record, for this world, one name per key of entries (key = name). An entry is a Trait, a Relation, { trait, codec?, refs? }, or { relation }. getTraitName(world, traitOrRelation) MUST return the registered name, and undefined for an unregistered one. A trait is recognised by typeof value === 'function' with a schema and an id, a relation by the exported $relation brand.
  • SER-002 [ecs][test]: Registration MUST be idempotent and cumulative: registering the same name for the same trait or relation again, with refs of equal contents and the same codec reference (or none), is a no-op, and earlier entries remain.
  • SER-003 [ecs][test]: registerTraits MUST throw a SerializeError naming registerTraits and the key, and register nothing from that call (it is atomic), with code invalid-registration if: (a) a value is neither a trait nor a relation nor an entry object; (b) a name is not a non-empty string, or is __proto__; © the entry is IsExcluded (never saved, SER-011); (d) codec or refs is given for a tag; (e) both codec and refs are given; (f) refs is given for an AoS trait, or names a field that is not in the SoA schema; (g) the trait is already ignored (SER-005). It MUST throw with code duplicate-name if a name is already used for a different trait or relation, the same trait is registered under a different name, or a repeated registration differs in refs or codec (SER-002).
  • SER-004 [ecs][test]: Registries MUST be per world (WeakMap<World, …>): registering in one world MUST NOT affect another. Registrations MUST survive world.reset(), because they describe app traits, not world state (as DEVTOOLS-004).
  • SER-005 [ecs][test]: ignoreTraits(world, items) (traits or relations) MUST mark them as deliberately not saved: saveWorld MUST NOT read them and MUST NOT report them as unregistered (SER-018). It MUST be idempotent. Ignoring a registered trait or relation, or registering an ignored one, MUST throw (invalid-registration). IsExcluded is always ignored and needs no call.
  • SER-006 [ecs][test]: When a relation is registered or ignored, the package MUST determine its backing trait (K3) without changing the user's world: world.traits and the set of entities are the same before and after, and no observer of that world is called. The scratch world MUST be destroyed even when an error is thrown: after registering 20 relations one after another, createWorld() MUST still succeed. Canary: the backing trait is found for a relation with data and for one without.
  • SER-007 [ecs][test]: Canary: a trait registered by name but never used in the world is not in world.traits, and registerTraits, ignoreTraits, getTraitName, and saveWorld leave world.traits unchanged (K1).
  • SER-008 [ecs][test]: Restore MUST resolve traits by name only, never by trait id or by the world that saved the snapshot. Registering the same names in a different order gives the same restored world; only the key order of a later saveWorld differs (SER-013, SER-092).

SER-F02 Save (M)

saveWorld(world) returns a WorldSnapshot.

  • SER-010 [ecs][test]: The result MUST be { format: 'ecs-serialize', version: 1, entities, world } and MUST be JSON-safe: JSON.parse(JSON.stringify(s)) deep-equals s.
  • SER-011 [ecs][test]: entities MUST hold one record per saved entity, ordered by entity.id() ascending (K4, K9). A trait-less entity MUST be included with an empty record. Excluded entities (prefabs, the hidden world entity) MUST NOT be listed, and destroyed entities MUST NOT either. An empty world MUST give entities: [].
  • SER-012 [ecs][test]: A record is { tags: string[], traits: Record<string, unknown>, pairs: Record<string, PairRecord[]> }, all three always present. tags lists the names of the registered tag traits the entity has. traits maps the name of each registered data trait the entity has to its JSON value (SER-015, SER-016, SER-033). pairs maps the name of each registered relation for which entity.targetsFor(relation) is non-empty to { target: snapshotIndex, data?: unknown }[]; data is present exactly when the relation has a store (non-empty schema) and holds entity.get(relation(target)) (SER-015).
  • SER-013 [ecs][test]: Order MUST be deterministic: tags, traits, and pairs keys follow registration order (keys of one call in key order, calls in order), pair targets follow targetsFor order, records follow SER-011. Two saves of an unchanged world MUST be deep-equal and give identical JSON.stringify text.
  • SER-014 [ecs][test]: world MUST be { tags, traits } with the same rules as a record, for the registered traits that world.has(trait) reports on the world entity (read with world.get). Pairs on the world entity are not supported.
  • SER-015 [ecs][test]: A SoA value (without codec or refs) MUST be saved as an object with exactly the schema's field names, each JSON-safe, as a copy: mutating the snapshot afterwards MUST NOT change the world, and mutating the world afterwards MUST NOT change the snapshot. A field that is not JSON-safe MUST throw not-serializable, naming saveWorld, the trait, and the field. Relation data follows the same rule (no codec or refs on relation data, v0).
  • SER-016 [ecs][test]: An AoS value without a codec MUST be saved as a deep copy if it is JSON-safe, and MUST otherwise throw not-serializable naming saveWorld and the trait (for example a Map, a class instance, a function, a cycle, NaN, or undefined inside). The package cannot see Entity numbers inside such a value: they are saved as the plain numbers they are (SER-030 gives the supported ways, OQ3).
  • SER-017 [ecs][test]: Values of tags MUST be listed in tags only; a tag never appears in traits.
  • SER-018 [ecs][test]: saveWorld MUST throw unregistered-trait, naming saveWorld and trait #<id>, if a saved entity or the world entity has a trait in world.traits that is not registered, not ignored, not IsExcluded, and not the backing trait of a registered or ignored relation (K3). Nothing is read from an excluded entity, so unregistered traits on prefabs do not throw. The check is by world.traits and entity.has only. There is no lenient mode (OQ6).
  • SER-019 [ecs][test]: A relation backing trait whose relation is unregistered and not ignored MUST be reported as in SER-018 (it cannot be told from a plain trait, so the error says trait #<id>).
  • SER-020 [ecs][test]: saveWorld MUST NOT change the world: after calling it, the set of entities, all trait values (SoA and AoS, by identity for AoS), the pairs, and world.traits are as before, and no app observer (onAdd, onRemove, onChange, onQueryAdd) is called.
  • SER-021 [ecs][test]: saveWorld MUST call has, get, and targetsFor only for traits and relation backing traits that are in world.traits. Canary: a world where a registered trait was never used saves without adding it to world.traits (K1, SER-007).
  • SER-022 [ecs][test]: Entities in a world that had destroys and respawns (ids with gaps, generations above 0) MUST be saved in entity.id() order with indices 0..n-1, and relation targets and refs MUST use those indices.
  • SER-023 [ecs][test]: Canary for K5: in a fresh world world.entities contains exactly one entity that has IsExcluded, and saveWorld returns entities: [] for it.

SER-F03 Entity references and codecs (M)

  • SER-030 [ecs][test]: { trait, refs: ['target'] } on a SoA trait declares that each listed top-level field holds an Entity or null. Saving MUST write the snapshot index of that entity, or null for null; restore MUST write the new entity, or null. A value that is neither null nor an alive saved entity MUST throw dangling-ref (SER-031).
  • SER-031 [ecs][test]: A reference (a refs field, a relation pair target, or ctx.ref in a codec) to an entity that is not a saved entity of this save (destroyed, stale, from another world, or excluded such as a prefab) MUST throw dangling-ref, naming saveWorld and the trait, field, or relation. Nothing is saved partially: the call returns nothing (OQ5).
  • SER-032 [ecs][test]: Relation pair targets MUST be saved as snapshot indices, and a pair whose target is not a saved entity MUST throw dangling-ref (SER-031).
  • SER-033 [ecs][test]: { trait, codec } MUST make the saved value codec.toJSON(value, ctx), where value is what entity.get(trait) returns (a fresh object for SoA, the stored reference for AoS; neither the package nor a well-behaved codec mutates it), and on restore the value MUST be codec.fromJSON(json, ctx) (the stored JSON, a deep copy of the snapshot value). A codec applies to SoA and AoS data traits. The toJSON result MUST be JSON-safe, otherwise not-serializable.
  • SER-034 [ecs][test]: The save context has ref(entity): number, which returns the snapshot index of a saved entity and otherwise throws dangling-ref. The restore context has entity(index): Entity, which returns the new entity for a valid index and otherwise throws malformed. entity MUST work for every index inside fromJSON, since all entities exist before any trait is applied (SER-056).
  • SER-035 [ecs][test]: An exception thrown by toJSON or fromJSON MUST surface as a SerializeError with code codec-error, naming the function and the trait, with the original error as cause. A SerializeError thrown through ctx keeps its own code.
  • SER-036 [ecs][test]: Round trip with a codec for an AoS trait holding a Map of entity to number (Map<Entity, number>): the restored Map has the new entities as keys and the same values.
  • SER-037 [ecs][test]: Round trip for a refs SoA trait with Entity | null (Intent, Targeting in the demo): the restored field is the new entity for the same record, and null stays null.
  • SER-038 [ecs][test]: A snapshot whose refs field is not null and not an integer index in 0..entities.length - 1 MUST be rejected on restore with malformed (SER-076), and a codec trait is not interpreted by the package at all (the JSON is only checked to be JSON-safe, SER-078).

SER-F04 Restore (M)

restoreWorld(world, snapshot: unknown): RestoreResult where RestoreResult is { entities: Entity[] }, indexed by snapshot index. Restore reads names from the target world's registry.

  • SER-050 [ecs][test]: Restore MUST spawn exactly one new entity per record, in snapshot order, with no IsExcluded, and return them in entities (entities[i] for record i, all alive in world). It MUST NOT spawn any other entity.
  • SER-051 [ecs][test]: Each listed tag MUST be added, and each data trait MUST end with the restored value: SoA with equal field values, AoS deep-equal and not === to the snapshot value (a copy), so that mutating a restored value MUST NOT change the snapshot, and mutating the snapshot afterwards MUST NOT change the world.
  • SER-052 [ecs][test]: Each pair MUST be restored on the mapped source: entity.targetsFor(relation) equals the mapped targets in snapshot order (K10), and entity.get(relation(target)) equals the saved data (a relation without a store has no data).
  • SER-053 [ecs][test]: The world record MUST be applied with world.add and world.set: afterwards world.has(trait) and world.get(trait) match the saved world traits.
  • SER-054 [ecs][test]: Round trip: for a world with entities that have tags, SoA, AoS with codec, refs, pairs with data, a trait-less entity, and world traits, saveWorld(A), then JSON.stringify and JSON.parse, then restoreWorld(B, parsed) on a fresh world with the same registrations, then saveWorld(B) MUST deep-equal the first snapshot. Canary for K9: restored entities have ascending ids in snapshot order.
  • SER-055 [ecs][test]: Restore MUST throw world-not-empty, before any change, if the world has a saved entity (alive, no IsExcluded) or any registered trait on its world entity (world.has). Excluded entities (prefabs) are allowed. Restore never merges into existing data, and never resets the world itself (SER-059).
  • SER-056 [ecs][test]: Restore MUST apply in this order: (1) spawn all entities, with no trait; (2) the world record; (3) for each record in snapshot order, its tags and data traits in snapshot key order; (4) for each record in snapshot order, its pairs in snapshot key order and target order. Phase 1 completes before any codec or ref is resolved, so ctx.entity(i) works for every i (SER-034). onAdd observers of a data trait registered before the restore MUST see the restored value (not a schema default) when they read it (K6; canary for AoS and SoA).
  • SER-057 [ecs][test]: Restore MUST NOT suppress or add observer calls: an onAdd or onQueryAdd observer of the target world registered before restore is called as for any entity, in the order of SER-056. Observers registered after restore are not called for restored entities (K8).
  • SER-058 [ecs][test]: Restore MUST validate the whole snapshot (SER-070..078) before the first change. If a later step fails (a codec throws, or koota throws), restore MUST destroy every entity it spawned and remove every world trait it added, then rethrow the SerializeError, so that the world again has no saved entity and no registered world trait. Test: a codec that throws on the 3rd record leaves a fresh world.
  • SER-059 [ecs][test]: Restore MUST NOT call world.reset(), register anything, or change the registry. A restore into a world that world.reset() just emptied MUST work and the registrations MUST still apply (SER-004; the demo's restart path, DND-034).
  • SER-060 [ecs][test]: A snapshot saved from world A MUST restore into a different fresh world B with the same names registered (maybe for different trait objects, SER-092), and one snapshot object restored into two worlds MUST give independent worlds: mutating a value in one MUST NOT change the other or the snapshot.
  • SER-061 [ecs][test]: Restore MUST NOT mutate the snapshot argument: a deep-frozen snapshot MUST restore successfully, and it is deep-equal to a copy made before.
  • SER-062 [ecs][test]: Restore MUST add tags with no parameters and SoA values as the trait's initial values, and relation pairs as relation(target, data), through entity.add and world.add (the public API only). If add of an AoS value does not store the given value (K6), the package MUST set it afterwards, with the same observable result as SER-056.
  • SER-063 [ecs][test]: Canary for K6: after restore, an AoS trait holds the restored value, not the factory default. The test documents the way the value was stored and fails if koota changes it.
  • SER-064 [ecs][test]: restoreWorld on an empty snapshot (entities: [], empty world) MUST succeed on a fresh world, return entities: [], and change nothing.

SER-F05 Snapshot validation and errors (M)

  • SER-070 [ecs][test]: Every error the package throws MUST be a SerializeError (extends Error) with a code and a message that begins with the function name (registerTraits:, saveWorld:, restoreWorld:) and names the trait, relation, field, or snapshot index that caused it. Codes: invalid-registration, duplicate-name, unregistered-trait (save), not-serializable (save), dangling-ref (save), codec-error, unknown-trait (restore), malformed (restore), unsupported-version (restore), world-not-empty (restore). The error never contains a whole value or snapshot.
  • SER-071 [ecs][test]: restoreWorld MUST throw malformed if the snapshot is not an object, or its format is not 'ecs-serialize'. It MUST throw unsupported-version if version is not the number 1, including a missing one, '1', 0, and 2, naming the version found. There are no migrations (out of scope).
  • SER-072 [ecs][test]: restoreWorld MUST throw malformed if entities is not an array, world is not an object, a record or world lacks tags (array of strings), traits (plain object), or pairs (plain object, records only; the world record has none), or the snapshot, a record, or a pair record has a key that the format does not define. (Strict on purpose, OQ8.)
  • SER-073 [ecs][test]: A name in tags, traits, pairs, or the world record that is not registered in the target world MUST throw unknown-trait, naming the name (flecs deser_unknown_component_*_strict). An ignored trait's name is unknown as well.
  • SER-074 [ecs][test]: A registered name used in the wrong place MUST throw malformed, naming it and its place: a data trait in tags, a tag in traits, a plain trait in pairs, a relation in tags or traits. A name that occurs twice in one tags array MUST throw malformed.
  • SER-075 [ecs][test]: A SoA value (trait value, pair data) MUST be a plain object whose keys are exactly the schema fields, and each field whose default is a primitive MUST have the same typeof, otherwise malformed, naming the trait and field (flecs deser_unknown_member; also covers a snapshot made with an older or newer schema, which is not migrated). Fields whose default comes from a factory only have to be JSON-safe. Trait values with a codec are exempt (SER-038).
  • SER-076 [ecs][test]: Every snapshot index (a refs field that is not null, a pair target) MUST be an integer i with 0 <= i < entities.length, otherwise malformed, naming the trait or relation and the index. A refs field is null or such an index; no other value is accepted.
  • SER-077 [ecs][test]: pairs[R] MUST be a non-empty array of { target, data? } objects, with no other key; data MUST be present exactly when relation R has a store (SER-012), and absent otherwise; a target that occurs twice for one relation of one entity MUST throw malformed.
  • SER-078 [ecs][test]: The snapshot MUST be JSON-safe throughout (the output of JSON.parse, or an in-memory object equal to one): functions, undefined, cycles, and non-finite numbers MUST throw malformed. An own key named __proto__, anywhere in the snapshot, MUST throw malformed, and restoring MUST NOT change Object.prototype or any other prototype, tested with JSON.parse('{"__proto__":{"x":1}}')-style input in a trait value.
  • SER-079 [ecs][test]: Each of SER-071..078 MUST be thrown before any change to the world: after the throw, world.entities, world.traits, and world trait values are as before (SER-058).
  • SER-080 [ecs][test]: A restoreWorld error never makes a later restore on the same world fail: after any error the same world restores a valid snapshot successfully (no rollback leftovers, no half-registered traits).

SER-F06 Package contract and isolation (S)

  • SER-090 [ecs][test]: Importing the package MUST NOT have side effects. The only module-level state is WeakMap<World, …> for the registry (SER-004), so two worlds, and a world and its snapshots, are independent.
  • SER-091 [ecs][test]: Registrations, ignores, and restores in one world MUST NOT change the other: after registerTraits(A, …), getTraitName(B, trait) is undefined, and saveWorld(B) still reports that trait as unregistered (SER-018).
  • SER-092 [ecs][test]: Names are the contract between save and restore. A snapshot from world A (Hp registered for trait Health) restored into a world B that registers Health as 'Health' MUST throw unknown-trait, and restored into a world B' that registers Hp for a different trait with the same schema MUST succeed and fill that trait.
  • SER-093 [ux][manual]: package.json MUST declare koota as a peerDependency and as an exact-version devDependency, set "sideEffects": false, and have no runtime dependencies. Verify with the Acceptance checklist (A1).
  • SER-094 [ux][manual]: The README MUST state: (a) names are the snapshot format, so renaming a registered name breaks old snapshots and there are no migrations; (b) every trait on a saved entity must be registered or ignored, otherwise saveWorld throws, and prefabs and the hidden world entity are not saved; © entity references inside trait values are remapped only through refs or a codec, and an Entity number inside an AoS value without one is saved as a plain number and will point to the wrong entity; (d) AoS values must be JSON-safe or have a codec, and relation data must be JSON-safe; (e) restore needs a fresh or reset world and does not reset it, observers fire during restore, so register them after restoreWorld (K7, K8); (f) handles from before a world.reset() must not be used afterwards, use RestoreResult.entities. Verify with the Acceptance checklist (A2).
  • SER-095 [ux][manual]: The package MUST use only koota's public exports (no $internal, no reference/) and MUST NOT depend on ecs-schedule, ecs-prefab, or ecs-devtools. Verify with the Acceptance checklist (A3).

SER-F07 demo-dnd scenario (S, test-only)

Uses traits like the demo's ECS model: Name, Position, Health (data); Hero, Monster, Defeated, Active, Effect (tags); Intent and Targeting (SoA with refs: ['target']); HasEffect (relation with data { turns, damage }) with one Poisoned effect entity; CombatLog and Cues (AoS, Cues with a codec that remaps the entities in its lists); an Encounter world trait (SoA round, status, movesLeft, acted); and Rng (AoS, ignored). All of them are registered with the names of the exports.

  • SER-100 [ecs][test]: A world with 3 heroes, 3 monsters, the Poisoned entity, and HasEffect(Poisoned) with { turns: 3, damage: 2 } on one monster: saveWorld, JSON.stringify, JSON.parse, and restoreWorld into a fresh world MUST give 7 entities with the same Name, Health, and Position values, the pair on the same monster with { turns: 3, damage: 2 }, and exactly one entity that has Effect, which is the pair's target.
  • SER-101 [ecs][test]: With Intent { kind: 'attack', target: monster } on the active hero and Targeting { mode: 'none', target: null }, the restored Intent.target MUST be the new entity whose Name is the monster's, and the null target stays null.
  • SER-102 [ecs][test]: A Cues with defeated: [goblin] MUST restore with defeated holding the new goblin entity, and CombatLog.entries MUST restore deep-equal, as a new array (not the saved array).
  • SER-103 [ecs][test]: With Rng registered as ignored and present as a world trait, saveWorld MUST NOT throw and the restored world MUST NOT have Rng; with Speed left unregistered on the heroes, saveWorld MUST throw unregistered-trait.
  • SER-104 [ecs][test]: Resume in the same world: saveWorld(world), world.reset(), restoreWorld(world, snapshot) MUST give world.query(Hero) with 3 entities and the pair intact, and the registrations from before the reset MUST still apply. A world.onQueryAdd(Defeated, cb) registered after the restore MUST be called exactly once for a later defeat, and not for restored entities (the DND-053 shape).
  • SER-105 [ecs][test]: A world in which some combatants were destroyed before the save (gaps in ids) MUST save and restore with all references (Intent, Targeting, the pair target, Cues) pointing at the right new entities (SER-022).
  • SER-106 [ecs][test]: Pairs from prefab instances (IsA, ecs-prefab) are not part of the v0 scenario: a relation that is neither registered nor ignored makes saveWorld throw unregistered-trait (SER-018), and once it is ignored (ignoreTraits(world, [IsA])) the pair is dropped silently and the restored monsters have no IsA pair (OQ11).

API sketch

import type { Entity, Relation, Trait, World } from 'koota';

export type SerializeErrorCode =
  | 'invalid-registration'
  | 'duplicate-name'
  | 'unregistered-trait'
  | 'not-serializable'
  | 'dangling-ref'
  | 'codec-error'
  | 'unknown-trait'
  | 'malformed'
  | 'unsupported-version'
  | 'world-not-empty';

export class SerializeError extends Error {
  readonly code: SerializeErrorCode;
  readonly cause?: unknown;
}

// Context passed to codecs.
export interface SaveContext {
  /** Snapshot index of a saved entity; throws `dangling-ref` otherwise. */
  ref(entity: Entity): number;
}
export interface RestoreContext {
  /** The new entity for a snapshot index; throws `malformed` for an invalid index. */
  entity(index: number): Entity;
}
export interface TraitCodec<V = unknown> {
  toJSON(value: V, ctx: SaveContext): unknown; // must return a JSON-safe value
  fromJSON(json: unknown, ctx: RestoreContext): V;
}

// Registry (per world). Keys become names; throws SerializeError on invalid input (SER-003).
export type TraitEntry =
  | Trait
  | Relation<Trait>
  | { trait: Trait; refs?: readonly string[] } // SoA fields holding Entity | null
  | { trait: Trait; codec: TraitCodec }
  | { relation: Relation<Trait> };
export function registerTraits(world: World, entries: Record<string, TraitEntry>): void;
export function ignoreTraits(world: World, items: readonly (Trait | Relation<Trait>)[]): void;
export function getTraitName(world: World, item: Trait | Relation<Trait>): string | undefined;

// Snapshot format (JSON-safe).
export interface PairRecord { target: number; data?: unknown }
export interface EntityRecord {
  tags: string[];
  traits: Record<string, unknown>;
  pairs: Record<string, PairRecord[]>;
}
export interface WorldRecord {
  tags: string[];
  traits: Record<string, unknown>;
}
export interface WorldSnapshot {
  format: 'ecs-serialize';
  version: 1;
  entities: EntityRecord[];
  world: WorldRecord;
}

export function saveWorld(world: World): WorldSnapshot;

export interface RestoreResult {
  /** New entity for each snapshot index. */
  entities: Entity[];
}
export function restoreWorld(world: World, snapshot: unknown): RestoreResult;

Usage for the demo (docs only):

registerTraits(world, {
  Name, Position, Health, Hero, Monster, Defeated, Active, Effect,
  Intent: { trait: Intent, refs: ['target'] },
  Targeting: { trait: Targeting, refs: ['target'] },
  Cues: { trait: Cues, codec: cuesCodec },
  CombatLog, Encounter, HasEffect,
});
ignoreTraits(world, [Rng]);

const text = JSON.stringify(saveWorld(world)); // e.g. to localStorage
// later:
world.reset(); // or a new world
const { entities } = restoreWorld(world, JSON.parse(text));
// then: reinstall Rng, register observers (DND-052, DND-089)

Notes for implementation:

  • Registry: WeakMap<World, { byName: Map<string, Entry>, byTrait: Map<Trait | Relation, string>, ignored: Set<…>, backing: Set<Trait> }>. Use Map and null-prototype objects for snapshot dictionaries, so names such as constructor and __proto__ are plain keys (SER-003, SER-078).
  • Backing trait probe (K3): one helper that creates createWorld(), spawns a and b, adds relation(b) to a, finds the trait in scratch.traits that a.has, and calls scratch.destroy() in finally. Do it once per relation per registry call and cache.
  • Save: build the saved entity list (world.entities, no IsExcluded, sorted by entity.id()), build Map<Entity, number>, then per entity loop the registered traits that are in world.traits (K1) in registration order; check unregistered traits in the same loop (SER-018).
  • Restore: validate everything first into an internal plan (names resolved, values checked, indices checked, SoA field sets checked), run codec fromJSON only in phase 3, and record every spawned entity and added world trait for rollback (SER-058). A deep copy helper for JSON-safe values is shared by save and restore.
  • First task: a runtime spike for the unverified items (K3, K6, K7, K9, K10, SER-006, SER-023, SER-063), written as the canary tests.

Differences from flecs

  • flecs saves by entity name and path and reads components through type reflection (ecs_struct). koota has neither names nor reflection, so apps register names (SER-001), the SoA schema is the reflection, and AoS needs a codec (flecs opaque types).
  • flecs ecs_world_from_json merges into a running world by entity name. Entity ids cannot be chosen in koota and entities have no names, so a snapshot refers to entities by index and restores only into a fresh world (SER-055), with an explicit old-to-new map (RestoreResult) and a remap of references inside values (SER-030).
  • flecs skips unknown components and members by default and fails only with strict. This package is always strict (SER-073, SER-075), since there are no migrations to bridge a mismatch (OQ8).
  • flecs saves every component of an entity. koota has no per-entity trait list, so only registered traits are saved, and an unregistered trait on a saved entity is an error (SER-018), not a silent loss.
  • flecs saves pairs as (Relation, Target) ids of a registered pair. Here a relation is registered by name, and a pair is { target, data? } under the relation name (SER-012).
  • flecs worlds are fully described by their entities. Here, world-level traits (singletons) have their own world record (SER-014), because koota stores them on the hidden world entity.
  • The binary, query, iterator, and REST variants of the flecs JSON addon are not ported.

Out of scope

  • Binary and compressed formats, streaming, delta snapshots, and partial (single-entity or query) snapshots. Text output is the caller's JSON.stringify; no storage (localStorage, files, network) code.
  • Migrations between snapshot versions, version negotiation, and a lenient mode for unknown names or extra fields (unsupported-version is a hard error). OQ8.
  • Prefabs (ecs-prefab): prefabs and other excluded entities are not saved, and IsA pairs need an explicit ignoreTraits (SER-106). Prefab serialization is a later work item once ecs-prefab has a stable shape. OQ11.
  • Schedule state: there is no ecs-schedule dependency. Its state world trait (SCH-063) is an ordinary registered world trait with name, disabled, and onStartDone, and the app decides to register it.
  • Codec and refs support on relation data (the demo's HasEffect data has no references), and refs for non-top-level fields or arrays of entities (use a codec).
  • Pairs on the world entity, and the relation options exclusive, autoDestroy, and ordered relations (K11).
  • Restoring into a non-fresh world, merging, entity-id preservation, and an implicit world.reset() (OQ7).
  • Observer, query, and action state, and the registrations themselves. restoreWorld never registers observers or names (SER-057, SER-059). The app re-registers them (DND-052, DND-089, DND-100).
  • Capture of Rng state (DND-033): Rng is an AoS { next() } and is ignored in the demo (OQ9).
  • Limits on snapshot size or nesting depth, authentication, or encryption of snapshots. Callers who read snapshots from untrusted storage validate their origin. Only the format is validated (SER-071..078).
  • Devtools integration (export or import button), and a React API. A save and resume UI is a demo work item (DND-F08).
  • Upstream candidates (not v0 work): entity.traits(), a public relation-backing marker (isRelationTrait), trait names, a reset event, and chosen entity ids on spawn. Each would remove a workaround here (K1, K3, K4, K7).

Open questions

Each has a recommended default. The requirements above already use the defaults. If the user decides otherwise, the named requirements change. The user approved this spec on 2026-11-01 and accepted all the defaults listed below.

  1. Snapshot format and version field. Default: { format: 'ecs-serialize', version: 1, entities, world } with the integer version, strict equality on restore, and no migrations (SER-010, SER-071). Alternative: only version, a semver string, or an app-supplied meta object (for example the encounter name) in the snapshot; not needed by DND-F08.
  2. Entity id remapping. Default: records are ordered by entity.id() and referenced by array index (K4, SER-011). RestoreResult.entities gives the old-to-new map by index. Alternative: also store the original entity.id() in each record for debugging (a larger snapshot and a field that must never be trusted), or an explicit id field with a Map result.
  3. Entity references in trait values. Default: declared per trait with refs (top-level SoA fields holding Entity | null) or converted by a codec (SER-030, SER-033), and nothing else is remapped: an Entity number in an AoS value without a codec is saved as a plain number (SER-016, documented in SER-094). It cannot be detected, since an Entity is a number. Alternatives: refs with paths ('targets[]', 'a.b'), or a codec required for every AoS trait (safer but verbose). A koota-side marker for entity-typed schema fields would fix it properly (upstream candidate).
  4. Codec API. Default: { toJSON(value, ctx), fromJSON(json, ctx) } with ctx.ref and ctx.entity (SER-033, SER-034). Alternative: two plain functions without a context, and only refs for entity remapping (simpler; Cues would then keep its lists as snapshot indices of the app's own making).
  5. Dangling references. Default: a reference to an entity that is not saved (destroyed or stale handle, excluded entity) throws dangling-ref and saves nothing (SER-031). The demo may keep a stale handle in Targeting or Cues, for example for a defeated and destroyed monster. Alternative: a save option to write null for dangling refs fields (not possible for pair targets, which would then be dropped).
  6. Unregistered traits and relation-backing traits. Default: saveWorld is strict (SER-018), ignoreTraits marks deliberate gaps (SER-005), and a scratch world finds relation-backing traits (K3, SER-006). Risks: a short-lived extra world per registered relation (world ids are limited to 16 at a time), and runtime behaviour of the probe is unverified. Alternatives: no strictness, so unregistered traits are silently lost (simple, but a forgotten Speed is saved as nothing); or a saveWorld(world, { unregistered: 'skip' }) option; or wait for a koota public marker.
  7. Restore target and world.reset(). Default: fresh or reset world only (world-not-empty otherwise), no implicit reset, and the app reinstalls observers afterwards (SER-055, SER-059). Alternative: restoreWorld(world, snapshot, { reset: true }). Unverified (K7): whether observers survive world.reset(), and whether an Entity from before the reset can alias a new one after ids restart. The scenario SER-104 is the canary for the demo's restart path; if it fails, the demo restores into a new world instead of resetting.
  8. Unknown names and extra fields. Default: throw (unknown-trait, malformed), the flecs strict behaviour (SER-072, SER-073, SER-075). Alternative: skip with a warning list in RestoreResult (lenient, as the flecs default). A lenient mode is the first step towards migrations and is therefore left to a later version.
  9. Rng in the demo. Default: ignoreTraits(world, [Rng]) and the demo reseeds on resume (SER-103). DND-F08 lists combatants, HP, effects, initiative position, and round, not the dice. Alternative: the demo's Rng exposes its state (a seed and calls count, or a state field), and registers a codec for it. A decision for the demo spec (DND-033), not for this package.
  10. Registry relation to ecs-devtools. Default: separate registries (registerTraits here, registerNames in ecs-devtools), so neither package depends on the other, at the cost of the demo registering twice (DND-100). Alternatives: ecs-serialize reads the devtools names (a cross-package dependency, breaks isolation), or a small shared names package (a new package, needs user approval).
  11. Prefabs and schedule state. Default: out of v0 (see Out of scope). Prefab instances carry IsA pairs to excluded prefabs, which are never saved, so an app with prefab-made combatants has to ignoreTraits(world, [IsA]) and loses the link, and then no longer has the template for later instantiation until the app re-creates its prefabs. A later work item can save prefabs as flagged records, once ecs-prefab's shape is fixed (its OQ6 about relation-backing traits is open). The schedule state needs nothing, as long as the app registers the world trait.
  12. Unverified koota behaviour. To be confirmed by the canary tests in the first implementation work item: the scratch-world probe (K3, SER-006); hidden world entity in world.entities (K5, SER-023); add([AosTrait, value]) storing the given value (K6, SER-063); spawn ids ascending in a fresh world (K9, SER-054); targetsFor order equal to add order (K10, SER-052); world.has / world.get on world traits (SER-014, SER-053); observers across world.reset() and handle aliasing (K7, SER-104). If a canary fails, the affected requirement and the matching K-item change.
  13. Dependencies. None are needed: JSON is built in, and koota is the only peer (SER-093). No approval is requested.
  14. Manual verification. [manual] requirements are verified by the Acceptance checklist below, which reads package files; no running app is needed.

Acceptance checklist

A human runs these after the package work items are done. Each box covers one [manual] requirement.

  • A1 (SER-093): package.json dependencies.
  • Run cat packages/ecs-serialize/package.json. Expected: peerDependencies lists koota; devDependencies has koota at an exact version (or catalog: whose entry in pnpm-workspace.yaml is an exact version); there is no dependencies entry; "sideEffects": false is present.
  • Run pnpm turbo run typecheck lint test --filter=@workspace/ecs-serialize. Expected: it passes, which shows the declared dependencies are enough.
  • A2 (SER-094): README content.
  • Run cat packages/ecs-serialize/README.md. Expected: it states all of (a) to (f) of SER-094. Tick off each one as found:
    • (a) names are the snapshot format; renaming breaks old snapshots; no migrations.
    • (b) every trait on a saved entity must be registered or ignored, otherwise saveWorld throws; prefabs and the hidden world entity are not saved.
    • © entity references are remapped only through refs or a codec; an Entity number in an AoS value without one points to the wrong entity.
    • (d) AoS values must be JSON-safe or have a codec; relation data must be JSON-safe.
    • (e) restore needs a fresh or reset world and does not reset it; register observers after restoreWorld.
    • (f) handles from before a world.reset() must not be used; use RestoreResult.entities.
  • A3 (SER-095): public API only, no sibling packages.
  • Run grep -rn "\$internal\|reference/" packages/ecs-serialize/src. Expected: no output.
  • Run grep -n "ecs-schedule\|ecs-prefab\|ecs-devtools" packages/ecs-serialize/package.json. Expected: no output.