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 withstrict; 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 forcodec).- 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;Relationobjects 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 byecs-prefabprefabs.- ecs-devtools spec: per-world
WeakMapname 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,restartEncounterwithworld.reset()), DND-052/053/089 (observers registered by setup), DND-100 (name registration), and the ECS model'sIntent,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 callingentity.has(trait), but only for traits that are inworld.traits(a trait entersworld.traitson first use in the world, and is never listed before; see ecs-devtools L3). Saving therefore never callshasorgeton 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 withentity.get(relation(target)). - K3 Relation-backing traits are not identifiable. The trait behind a relation is in
world.traitsand cannot be told from a plain trait through the public API (it hides behind$internal; ecs-devtools L5).saveWorldhas 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 withcreateWorld(): 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; anEntityis 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 theentitiesarray, and restore remaps (SER-050, SER-030). - K5 The world entity and excluded entities.
world.entitiesincludes the hidden world entity, which carriesIsExcluded(source; ecs-devtools L1 and its canary DEVTOOLS-013). Prefabs carry it too.saveWorldnever saves an entity that hasIsExcluded; 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.schemais an empty object, AoS if it is a function, otherwise SoA.entity.getreturns a fresh object for SoA and the stored reference for AoS. Whetheradd([AosTrait, value])storesvalueas 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, clearsworld.traits, drops queries and tracking, and recreates the entity index so ids restart (source:world.ts). Whether observers registered withonAdd,onRemove,onChange, oronQueryAddsurvive is unverified, and apps must assume they do not (ecs-devtools L9; DND-053). Whether anEntityobtained 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 fireonAddandonQueryAddobservers 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 ascendingentity.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
registerTraitsfor this world, ignored if it was passed toignoreTraits. - A saved entity is an alive entity in
world.entitiesthat does not haveIsExcluded. The world record describes the traits of the hidden world entity. - The kind of a trait is
tag,soa, oraos(K6). A data trait issoaoraos. - 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 (prototypeObject.prototypeornull) 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 afterworld.reset().
SER-F01 Trait registry (S)¶
- SER-001 [ecs][test]:
registerTraits(world, entries)MUST record, for this world, one name per key ofentries(key = name). An entry is aTrait, aRelation,{ trait, codec?, refs? }, or{ relation }.getTraitName(world, traitOrRelation)MUST return the registered name, andundefinedfor an unregistered one. A trait is recognised bytypeof value === 'function'with aschemaand anid, a relation by the exported$relationbrand. - SER-002 [ecs][test]: Registration MUST be idempotent and cumulative: registering the same name for the same trait or relation again, with
refsof equal contents and the samecodecreference (or none), is a no-op, and earlier entries remain. - SER-003 [ecs][test]:
registerTraitsMUST throw aSerializeErrornamingregisterTraitsand the key, and register nothing from that call (it is atomic), with codeinvalid-registrationif: (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 isIsExcluded(never saved, SER-011); (d)codecorrefsis given for a tag; (e) bothcodecandrefsare given; (f)refsis 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 codeduplicate-nameif 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 inrefsorcodec(SER-002). - SER-004 [ecs][test]: Registries MUST be per world (
WeakMap<World, …>): registering in one world MUST NOT affect another. Registrations MUST surviveworld.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:saveWorldMUST 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).IsExcludedis 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.traitsand 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, andregisterTraits,ignoreTraits,getTraitName, andsaveWorldleaveworld.traitsunchanged (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
saveWorlddiffers (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-equalss. - SER-011 [ecs][test]:
entitiesMUST hold one record per saved entity, ordered byentity.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 giveentities: []. - SER-012 [ecs][test]: A record is
{ tags: string[], traits: Record<string, unknown>, pairs: Record<string, PairRecord[]> }, all three always present.tagslists the names of the registered tag traits the entity has.traitsmaps the name of each registered data trait the entity has to its JSON value (SER-015, SER-016, SER-033).pairsmaps the name of each registered relation for whichentity.targetsFor(relation)is non-empty to{ target: snapshotIndex, data?: unknown }[];datais present exactly when the relation has a store (non-empty schema) and holdsentity.get(relation(target))(SER-015). - SER-013 [ecs][test]: Order MUST be deterministic:
tags,traits, andpairskeys follow registration order (keys of one call in key order, calls in order), pair targets followtargetsFororder, records follow SER-011. Two saves of an unchanged world MUST be deep-equal and give identicalJSON.stringifytext. - SER-014 [ecs][test]:
worldMUST be{ tags, traits }with the same rules as a record, for the registered traits thatworld.has(trait)reports on the world entity (read withworld.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, namingsaveWorld, 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-serializablenamingsaveWorldand the trait (for example a Map, a class instance, a function, a cycle,NaN, orundefinedinside). 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
tagsonly; a tag never appears intraits. - SER-018 [ecs][test]:
saveWorldMUST throwunregistered-trait, namingsaveWorldandtrait #<id>, if a saved entity or the world entity has a trait inworld.traitsthat is not registered, not ignored, notIsExcluded, 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 byworld.traitsandentity.hasonly. 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]:
saveWorldMUST NOT change the world: after calling it, the set of entities, all trait values (SoA and AoS, by identity for AoS), the pairs, andworld.traitsare as before, and no app observer (onAdd,onRemove,onChange,onQueryAdd) is called. - SER-021 [ecs][test]:
saveWorldMUST callhas,get, andtargetsForonly for traits and relation backing traits that are inworld.traits. Canary: a world where a registered trait was never used saves without adding it toworld.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 indices0..n-1, and relation targets and refs MUST use those indices. - SER-023 [ecs][test]: Canary for K5: in a fresh world
world.entitiescontains exactly one entity that hasIsExcluded, andsaveWorldreturnsentities: []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 anEntityornull. Saving MUST write the snapshot index of that entity, ornullfornull; restore MUST write the new entity, ornull. A value that is neithernullnor an alive saved entity MUST throwdangling-ref(SER-031). - SER-031 [ecs][test]: A reference (a
refsfield, a relation pair target, orctx.refin 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 throwdangling-ref, namingsaveWorldand 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 valuecodec.toJSON(value, ctx), wherevalueis whatentity.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 becodec.fromJSON(json, ctx)(the stored JSON, a deep copy of the snapshot value). A codec applies to SoA and AoS data traits. ThetoJSONresult MUST be JSON-safe, otherwisenot-serializable. - SER-034 [ecs][test]: The save context has
ref(entity): number, which returns the snapshot index of a saved entity and otherwise throwsdangling-ref. The restore context hasentity(index): Entity, which returns the new entity for a valid index and otherwise throwsmalformed.entityMUST work for every index insidefromJSON, since all entities exist before any trait is applied (SER-056). - SER-035 [ecs][test]: An exception thrown by
toJSONorfromJSONMUST surface as aSerializeErrorwith codecodec-error, naming the function and the trait, with the original error ascause. ASerializeErrorthrown throughctxkeeps its own code. - SER-036 [ecs][test]: Round trip with a codec for an AoS trait holding a
Mapof entity to number (Map<Entity, number>): the restoredMaphas the new entities as keys and the same values. - SER-037 [ecs][test]: Round trip for a
refsSoA trait withEntity | null(Intent,Targetingin the demo): the restored field is the new entity for the same record, andnullstaysnull. - SER-038 [ecs][test]: A snapshot whose
refsfield is notnulland not an integer index in0..entities.length - 1MUST be rejected on restore withmalformed(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 inentities(entities[i]for recordi, all alive inworld). 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), andentity.get(relation(target))equals the saveddata(a relation without a store has no data). - SER-053 [ecs][test]: The world record MUST be applied with
world.addandworld.set: afterwardsworld.has(trait)andworld.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), thenJSON.stringifyandJSON.parse, thenrestoreWorld(B, parsed)on a fresh world with the same registrations, thensaveWorld(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, noIsExcluded) 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 everyi(SER-034).onAddobservers 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
onAddoronQueryAddobserver 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 thatworld.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), throughentity.addandworld.add(the public API only). Ifaddof 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]:
restoreWorldon an empty snapshot (entities: [], emptyworld) MUST succeed on a fresh world, returnentities: [], 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 acodeand amessagethat 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]:
restoreWorldMUST throwmalformedif the snapshot is not an object, or itsformatis not'ecs-serialize'. It MUST throwunsupported-versionifversionis not the number1, including a missing one,'1',0, and2, naming the version found. There are no migrations (out of scope). - SER-072 [ecs][test]:
restoreWorldMUST throwmalformedifentitiesis not an array,worldis not an object, a record orworldlackstags(array of strings),traits(plain object), orpairs(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 throwunknown-trait, naming the name (flecsdeser_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 intags, a tag intraits, a plain trait inpairs, a relation intagsortraits. A name that occurs twice in onetagsarray MUST throwmalformed. - 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 sametypeof, otherwisemalformed, naming the trait and field (flecsdeser_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
refsfield that is notnull, a pairtarget) MUST be an integeriwith0 <= i < entities.length, otherwisemalformed, naming the trait or relation and the index. Arefsfield isnullor 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;dataMUST be present exactly when relationRhas a store (SER-012), and absent otherwise; atargetthat occurs twice for one relation of one entity MUST throwmalformed. - 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 throwmalformed. An own key named__proto__, anywhere in the snapshot, MUST throwmalformed, and restoring MUST NOT changeObject.prototypeor any other prototype, tested withJSON.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
restoreWorlderror 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)isundefined, andsaveWorld(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 (
Hpregistered for traitHealth) restored into a world B that registersHealthas'Health'MUST throwunknown-trait, and restored into a world B' that registersHpfor a different trait with the same schema MUST succeed and fill that trait. - SER-093 [ux][manual]:
package.jsonMUST declarekootaas apeerDependencyand as an exact-versiondevDependency, set"sideEffects": false, and have no runtimedependencies. 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
saveWorldthrows, and prefabs and the hidden world entity are not saved; © entity references inside trait values are remapped only throughrefsor acodec, 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 afterrestoreWorld(K7, K8); (f) handles from before aworld.reset()must not be used afterwards, useRestoreResult.entities. Verify with the Acceptance checklist (A2). - SER-095 [ux][manual]: The package MUST use only koota's public exports (no
$internal, noreference/) and MUST NOT depend onecs-schedule,ecs-prefab, orecs-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
Poisonedentity, andHasEffect(Poisoned)with{ turns: 3, damage: 2 }on one monster:saveWorld,JSON.stringify,JSON.parse, andrestoreWorldinto a fresh world MUST give 7 entities with the sameName,Health, andPositionvalues, the pair on the same monster with{ turns: 3, damage: 2 }, and exactly one entity that hasEffect, which is the pair's target. - SER-101 [ecs][test]: With
Intent { kind: 'attack', target: monster }on the active hero andTargeting { mode: 'none', target: null }, the restoredIntent.targetMUST be the new entity whoseNameis the monster's, and thenulltarget staysnull. - SER-102 [ecs][test]: A
Cueswithdefeated: [goblin]MUST restore withdefeatedholding the new goblin entity, andCombatLog.entriesMUST restore deep-equal, as a new array (not the saved array). - SER-103 [ecs][test]: With
Rngregistered as ignored and present as a world trait,saveWorldMUST NOT throw and the restored world MUST NOT haveRng; withSpeedleft unregistered on the heroes,saveWorldMUST throwunregistered-trait. - SER-104 [ecs][test]: Resume in the same world:
saveWorld(world),world.reset(),restoreWorld(world, snapshot)MUST giveworld.query(Hero)with 3 entities and the pair intact, and the registrations from before the reset MUST still apply. Aworld.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 makessaveWorldthrowunregistered-trait(SER-018), and once it is ignored (ignoreTraits(world, [IsA])) the pair is dropped silently and the restored monsters have noIsApair (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> }>. UseMapand null-prototype objects for snapshot dictionaries, so names such asconstructorand__proto__are plain keys (SER-003, SER-078). - Backing trait probe (K3): one helper that creates
createWorld(), spawnsaandb, addsrelation(b)toa, finds the trait inscratch.traitsthata.has, and callsscratch.destroy()infinally. Do it once per relation per registry call and cache. - Save: build the saved entity list (
world.entities, noIsExcluded, sorted byentity.id()), buildMap<Entity, number>, then per entity loop the registered traits that are inworld.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
fromJSONonly 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_jsonmerges 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
worldrecord (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-versionis a hard error). OQ8. - Prefabs (
ecs-prefab): prefabs and other excluded entities are not saved, andIsApairs need an explicitignoreTraits(SER-106). Prefab serialization is a later work item onceecs-prefabhas a stable shape. OQ11. - Schedule state: there is no
ecs-scheduledependency. Itsstateworld trait (SCH-063) is an ordinary registered world trait withname,disabled, andonStartDone, and the app decides to register it. - Codec and
refssupport on relation data (the demo'sHasEffectdata has no references), andrefsfor 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.
restoreWorldnever registers observers or names (SER-057, SER-059). The app re-registers them (DND-052, DND-089, DND-100). - Capture of
Rngstate (DND-033):Rngis 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.
- Snapshot format and version field. Default:
{ format: 'ecs-serialize', version: 1, entities, world }with the integerversion, strict equality on restore, and no migrations (SER-010, SER-071). Alternative: onlyversion, a semver string, or an app-suppliedmetaobject (for example the encounter name) in the snapshot; not needed by DND-F08. - Entity id remapping. Default: records are ordered by
entity.id()and referenced by array index (K4, SER-011).RestoreResult.entitiesgives the old-to-new map by index. Alternative: also store the originalentity.id()in each record for debugging (a larger snapshot and a field that must never be trusted), or an explicitidfield with a Map result. - Entity references in trait values. Default: declared per trait with
refs(top-level SoA fields holdingEntity | null) or converted by acodec(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:refswith paths ('targets[]','a.b'), or acodecrequired for every AoS trait (safer but verbose). A koota-side marker for entity-typed schema fields would fix it properly (upstream candidate). - Codec API. Default:
{ toJSON(value, ctx), fromJSON(json, ctx) }withctx.refandctx.entity(SER-033, SER-034). Alternative: two plain functions without a context, and onlyrefsfor entity remapping (simpler;Cueswould then keep its lists as snapshot indices of the app's own making). - Dangling references. Default: a reference to an entity that is not saved (destroyed or stale handle, excluded entity) throws
dangling-refand saves nothing (SER-031). The demo may keep a stale handle inTargetingorCues, for example for a defeated and destroyed monster. Alternative: a save option to writenullfor danglingrefsfields (not possible for pair targets, which would then be dropped). - Unregistered traits and relation-backing traits. Default:
saveWorldis strict (SER-018),ignoreTraitsmarks 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 forgottenSpeedis saved as nothing); or asaveWorld(world, { unregistered: 'skip' })option; or wait for a koota public marker. - Restore target and
world.reset(). Default: fresh or reset world only (world-not-emptyotherwise), no implicit reset, and the app reinstalls observers afterwards (SER-055, SER-059). Alternative:restoreWorld(world, snapshot, { reset: true }). Unverified (K7): whether observers surviveworld.reset(), and whether anEntityfrom 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. - Unknown names and extra fields. Default: throw (
unknown-trait,malformed), the flecsstrictbehaviour (SER-072, SER-073, SER-075). Alternative: skip with a warning list inRestoreResult(lenient, as the flecs default). A lenient mode is the first step towards migrations and is therefore left to a later version. Rngin 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'sRngexposes its state (aseedandcallscount, or astatefield), and registers a codec for it. A decision for the demo spec (DND-033), not for this package.- Registry relation to ecs-devtools. Default: separate registries (
registerTraitshere,registerNamesin 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). - Prefabs and schedule state. Default: out of v0 (see Out of scope). Prefab instances carry
IsApairs to excluded prefabs, which are never saved, so an app with prefab-made combatants has toignoreTraits(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, onceecs-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. - 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);targetsFororder equal to add order (K10, SER-052);world.has/world.geton world traits (SER-014, SER-053); observers acrossworld.reset()and handle aliasing (K7, SER-104). If a canary fails, the affected requirement and the matching K-item change. - Dependencies. None are needed: JSON is built in, and koota is the only peer (SER-093). No approval is requested.
- 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:peerDependencieslistskoota;devDependencieshaskootaat an exact version (orcatalog:whose entry inpnpm-workspace.yamlis an exact version); there is nodependenciesentry;"sideEffects": falseis 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
saveWorldthrows; prefabs and the hidden world entity are not saved. - © entity references are remapped only through
refsor acodec; 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; useRestoreResult.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.