Status: approved
ecs-devtools¶
Summary¶
@workspace/ecs-devtools v0 is a live, read-only entity and trait inspector for a koota world, in the spirit of the flecs Explorer. It has three layers: a headless snapshot API (listEntities, inspectEntity), a live store that keeps a snapshot current through koota observers and a poll (createWorldStore), and a React panel built on @workspace/ui (WorldInspector). koota has no trait names, no per-entity trait list, and no entity-created or entity-destroyed event, so the package works within the limits listed below: apps register names for their traits and relations, and trait-less entity creation is caught by a poll. The driving demo is apps/demo-dnd (brief §4, §5, DND-F10): the panel lists combatants with their traits and effects (HasEffect(Poisoned) with its duration) live, and never lists monster templates unless asked. v0 needs no ecs-serialize.
References¶
- flecs: FlecsRemoteApi.md (the Explorer's entity and component views; read for what an entity view shows: components, tags, pairs with data). Only the entity list, entity detail, and filter are in scope; the Explorer's query, graph, and statistics views are not.
- koota (public API only, read from
reference/koota/packages/core/srcandreference/koota/packages/react/src): world.entities(alive entities, a fresh array),world.has(entity),entity.id(),entity.generation().world.traits(Set<Trait>),entity.has(trait),entity.get(trait),trait.schema,trait.id.- Observers:
world.onAdd(trait, cb),world.onRemove(trait, cb),world.onChange(trait, cb). All return an unsubscribe function. ARelationis accepted by all three, and the callback gets(entity, target). - Relations:
Relationobjects are branded with the exported$relationsymbol. Pairs are read withentity.targetsFor(relation)andentity.get(relation(target)). IsExcluded(exported tag): every koota query skips entities that have it. The hidden world entity andecs-prefabprefabs have it.koota/react:useWorld(),WorldProvider. React'suseSyncExternalStoreconnects the store to components.- ecs-prefab spec: prefabs carry
IsExcluded, and its OQ6 (relation-backed traits inworld.traits) is resolved from the devtools side in OQ1 below. - ecs-schedule spec: the schedule's
stateworld trait has anamefield. Registering it is up to the app (DEVTOOLS-001). Devtools does not depend onecs-schedulein v0. @workspace/uicomponents (packages/ui/src/components):ScrollArea,Item,Collapsible,Badge,Input,Empty,Separator,Toggle. There is no table component.- 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 yet confirmed at runtime. Each has a canary test (DEVTOOLS-006, DEVTOOLS-013, DEVTOOLS-034) that fails if koota changes.
- L1 Entity list.
world.entitiesreturns all alive entities, including the hidden world entity that carriesIsExcluded(source:world.tsinitandreset). Prefabs also carryIsExcluded. The world entity cannot be told apart from a prefab through the public API. - L2 No entity lifecycle events. There is no event for entity creation or destruction. Destroying an entity fires
onRemoveonce for each trait it had (source). An entity with no traits gives no signal when it is spawned or destroyed. Whetherworld.onQueryAdd([])reports trait-less entities is unverified, and v0 does not rely on it. The store catches these cases by polling (DEVTOOLS-035). - L3
world.traits. ASet<Trait>of every trait registered in this world. It includesIsExcludedand the trait behind each relation, is cleared only byworld.reset(), and has no growth event. A trait is registered on first use in the world, or when an observer is attached to it. Attaching an observer to a trait the world has not seen therefore changesworld.traits, so the store only observes traits already in the set (DEVTOOLS-042). - L4 No per-entity trait list. There is no
entity.traits(). The traits of an entity are found by loopingworld.traitswithentity.has(trait). - L5 Relation-backed traits. The trait behind a relation cannot be distinguished from a plain trait, because the marker is
trait[$internal].relation. The inspector therefore never has to tell them apart: a trait is shown by name if the app registered it, otherwise as an unregistered trait without its value (DEVTOOLS-016). Pairs are read only through registeredRelationobjects (DEVTOOLS-014). - L6 No names. Traits and relations have no names (
trait.idis a per-module counter). Names come from the app's registry (DEVTOOLS-001). Entities have no names either; a label function is optional (DEVTOOLS-005). - L7 Change detection is opt-in.
world.onChange(trait, cb)marks the trait as tracked, soupdateEachstarts to detect and report changes for it. The store keeps change observers only while an entity is selected, and only for that entity's registered traits (DEVTOOLS-040, DEVTOOLS-041). In-place mutation of an AoS value is invisible withoutentity.changed(trait), so selected values are also re-read on every poll (DEVTOOLS-036). - L8 Values.
entity.get(trait)returns a fresh object for SoA traits and the stored reference for AoS traits. The inspector never keeps or mutates the reference. It formats it to text at read time (DEVTOOLS-020). - L9 No reset event. The public API has no notification for
world.reset(), which also drops every observer.store.refresh()re-establishes them (DEVTOOLS-037, DEVTOOLS-038). - L10 React hooks. No
koota/reacthook enumeratesworld.traitsor fires on entity creation or destruction (useQueryneeds a trait to match). The panel uses the package's own store withuseSyncExternalStore, anduseWorldfor the world. - L11 Read-only. The package never calls
add,remove,set,changed,spawn, ordestroy(DEVTOOLS-026).
Requirements¶
All requirements are [ecs] (headless model behaviour: snapshots and the store) unless tagged [ux] (what the user of the panel sees or does). All are [test] (Vitest, fresh koota world per test; panel tests use Testing Library with a real world in WorldProvider) unless tagged [manual]. Time (poll, scheduling) is injected, never patched (DEVTOOLS-032). The groups below are sized so that Triage can create one feature item per group: F01 S, F02 M, F03 M, F04 M, F05 S, F06 S (test-only, driven by the demo). New IDs are numbered within the group's range.
Terms: a trait is registered if the app passed it to registerNames for this world. An entity is excluded if it has IsExcluded. The world entity is koota's hidden excluded entity (L1). Detail is the result of inspectEntity. Kind is tag if trait.schema is an empty object, aos if it is a function, otherwise soa.
DEVTOOLS-F01 Name registry (S)¶
- DEVTOOLS-001 [ecs][test]:
registerNames(world, names)MUST record, for this world, the name of each entry ofnames(key = name, value = aTraitor aRelation).getName(world, traitOrRelation)MUST return the registered name, andundefinedfor an unregistered one. A trait is recognised bytypeof value === 'function'with aschemaand anid. A relation is recognised by the exported$relationbrand. - DEVTOOLS-002 [ecs][test]: Registration MUST be idempotent and cumulative: calling
registerNamesagain with the same name for the same trait is a no-op, and entries from earlier calls remain. - DEVTOOLS-003 [ecs][test]:
registerNamesMUST throw, namingregisterNamesand the key, if (a) a value is neither a trait nor a relation, (b) a name is already used for a different trait or relation, or © the same trait is registered under a different name. Nothing from that call is registered (it is atomic). - DEVTOOLS-004 [ecs][test]: Names MUST be per world (
WeakMap<World, …>): registering in one world MUST NOT affect another world. Names MUST surviveworld.reset(), because they describe app traits, not world state.IsExcludedMUST have the built-in nameIsExcludedin every world and MUST NOT be re-registrable under another name. - DEVTOOLS-005 [ecs][test]:
setEntityLabel(world, fn)sets the label function for the world (undefinedclears it). An entity's label MUST befn(entity)if that returns a non-empty string, otherwise#<entity.id()>. Iffnthrows, the label MUST be the default label (a debug panel must keep working). The same function labels pair targets (DEVTOOLS-014). - DEVTOOLS-006 [ecs][test]: Canary: a trait registered by name but never used in the world is not in
world.traits, andregisterNames,listEntities, andinspectEntityleaveworld.traitsunchanged (L3).
DEVTOOLS-F02 Entity list and entity detail (M)¶
- DEVTOOLS-010 [ecs][test]:
listEntities(world)MUST return oneEntitySummaryfor every alive entity inworld.entitiesthat is not excluded, ordered byentity.id()ascending. A trait-less entity (world.spawn()) MUST be included, with no traits. - DEVTOOLS-011 [ecs][test]:
listEntities(world, { includeExcluded: true })MUST also return the excluded entities (prefabs, the world entity) withexcluded: true. Without the option, an entity that getsIsExcludedlater MUST disappear from the next list. - DEVTOOLS-012 [ecs][test]: An
EntitySummaryMUST havetraits: the names of the registered traits the entity has (entity.has), in registration order (DEVTOOLS-019), andunregisteredCount: the number of unregistered traits inworld.traitsthat it has. Tags and data traits are treated alike. - DEVTOOLS-013 [ecs][test]: Canary for L1:
world.entitiesincludes exactly one excluded entity in a fresh world, and it is not returned bylistEntities(world)but is returned withincludeExcluded: true. - DEVTOOLS-014 [ecs][test]: Pairs: for each registered relation, an entity with at least one target (
entity.targetsFor(relation)) MUST have, inEntitySummary.pairs, the relation's name once, and in its detail onePairEntryper target:{ relation, target, targetLabel, value, text }, wherevalueisentity.get(relation(target))(undefinedif the relation has no data) andtargetLabelis the label of the target (DEVTOOLS-005). Targets are intargetsFororder. - DEVTOOLS-015 [ecs][test]:
inspectEntity(world, entity)MUST return{ entity, label, alive, excluded, traits, pairs, unregistered }. Each registered trait the entity has MUST appear intraitsas{ name, kind, value, text }:valueisentity.get(trait)(undefinedfor a tag) andtextisformatValue(value)(an empty string for a tag). - DEVTOOLS-016 [ecs][test]: Each unregistered trait in
world.traitsthat the entity has MUST appear inunregisteredas{ id: trait.id, kind }, ordered byid. The entry MUST NOT have avalueortext: the inspector does not read values of traits it cannot name, which includes the traits behind relations (L5, OQ4). - DEVTOOLS-017 [ecs][test]:
inspectEntityfor an entity that is not alive inworld(destroyed, stale, or from another world; checked withworld.has(entity)) MUST NOT throw, and MUST returnalive: falsewith emptytraits,pairs, andunregistered. - DEVTOOLS-018 [ecs][test]: A registered trait whose
schemais a function MUST havekind: 'aos', an empty objectkind: 'tag', and any otherkind: 'soa'. The AoSvalueis the stored reference, and the inspector MUST NOT mutate it. - DEVTOOLS-019 [ecs][test]: Order MUST be deterministic: registered traits and relations in the order
registerNamesrecorded them (key order within one call, calls in order),IsExcludedfirst when shown, and unregistered traits bytrait.id. - DEVTOOLS-020 [ecs][test]:
formatValue(value)MUST return a single-line string and MUST NOT throw.undefined,null, booleans, and numbers useString(); strings are JSON-quoted; bigints are1n; symbols areSymbol(desc); functions are[Function name]([Function (anonymous)]). Plain objects are{ hp: 7, max: 7 }inObject.keysorder, arrays[1, 2, 3],MapisMap(1) { "a" => 1 },SetisSet(2) { 1, 2 }, and a class instance is prefixed with its class name (Vec { x: 1 }). - DEVTOOLS-021 [ecs][test]:
formatValueMUST cut off: nesting deeper than 3 levels prints[Object]or[Array]; a cycle prints[Circular]; a string longer than 200 characters is truncated with…; an object, array, map, or set with more than 20 entries prints the first 20 then… N more; a property whose read throws prints[Unreadable]. - DEVTOOLS-022 [ecs][test]:
listEntitiesandinspectEntityMUST NOT change the world: after calling them, the set of entities, the trait values, andworld.traitsare as before (DEVTOOLS-026, DEVTOOLS-006).
DEVTOOLS-F03 Live store (M)¶
createWorldStore(world, options?) returns a store with subscribe, getSnapshot, select, setIncludeExcluded, refresh, and dispose, shaped for useSyncExternalStore. WorldSnapshot is { version, includeExcluded, entities, selected }: entities is listEntities and selected is the detail of the selected entity (or null).
- DEVTOOLS-030 [ecs][test]:
getSnapshot()MUST equallistEntities(world, { includeExcluded })forentities, andinspectEntity(world, entity)forselected(nullif nothing is selected), withversionstarting at 0. - DEVTOOLS-031 [ecs][test]:
getSnapshot()MUST return the same object while nothing changed, so that React does not loop. Equality compareslabel,traits,pairs,unregisteredCount,excluded, and each detail'stextfields, nevervalueidentity. A change MUST produce a new object withversionincreased by one. - DEVTOOLS-032 [ecs][test]: Time MUST be injected:
options.schedule(flush)(defaultqueueMicrotask) andoptions.timer({ setInterval, clearInterval }, default the globals) so tests need no patched globals.options.pollMsdefaults to 500;0disables the poll. - DEVTOOLS-033 [ecs][test]: The store MUST NOT observe the world until the first
subscribe, and MUST remove every observer, change tracking, and timer when the last listener unsubscribes ordispose()is called. Afterwards no listener is called, andgetSnapshot()still returns a correct snapshot (it rebuilds on each call and returns the previous object if equal). A newsubscribeafter the last unsubscribe MUST work; afterdispose()it MUST be a no-op. - DEVTOOLS-034 [ecs][test]: While subscribed, the store MUST observe
onAddandonRemovefor every trait inworld.traits. An entity that gets or loses any trait (registered or not, relation pairs included) MUST cause one notification, and the next snapshot MUST show it. Canary: destroying an entity with a trait MUST notify (L2). - DEVTOOLS-035 [ecs][test]: Entity creation or destruction that no trait observer sees (a trait-less
world.spawn()and its destroy) MUST be reflected within one poll tick ofoptions.timer, and not before. WithpollMs: 0it is reflected on the nextrefresh(). - DEVTOOLS-036 [ecs][test]: Every poll tick MUST re-list the entities and re-read the selected entity. An in-place mutation of a selected AoS value (with no
entity.changed) MUST show in the next snapshot after the tick. A tick that finds nothing different MUST NOT notify and MUST NOT changeversion. - DEVTOOLS-037 [ecs][test]: A trait that first appears in
world.traitsafter subscribe (L3) MUST be observed from the next poll tick orrefresh().refresh()MUST synchronously rebuild the snapshot, notify if it changed, and re-establish every observer from scratch (L9). - DEVTOOLS-038 [ecs][test]: After
world.reset()andrefresh()the store MUST work with the new entities. A poll tick MUST re-establish observers by itself if a trait it observed is no longer inworld.traits(the reset signal that is detectable, OQ6). - DEVTOOLS-039 [ecs][test]: Coalescing: any number of changes in one synchronous batch (e.g. a turn that spawns and changes 30 entities) MUST produce one listener call per
scheduleflush. - DEVTOOLS-040 [ecs][test]:
select(entity)MUST setselectedto its detail, andselect(null)tonull. While an entity is selected,set(trait, …)on it for a registered trait MUST updateselected(throughonChange). Changes to other entities MUST NOT produce a new snapshot unless the listed names or counts change. - DEVTOOLS-041 [ecs][test]:
world.onChangeMUST be called only for the selected entity's registered traits that are inworld.traits, and for registered relations that the selected entity has a pair for (so no trait gets registered by the store, DEVTOOLS-042). Change observers MUST be removed onselect(null)and when another entity is selected. L7. - DEVTOOLS-042 [ecs][test]: The store MUST NOT add a trait to
world.traits: observers are attached only to traits already in the set. Opening a store for a world where a registered trait was never used leavesworld.traitsunchanged. - DEVTOOLS-043 [ecs][test]: If the selected entity is destroyed,
selectedMUST become its detail withalive: false(DEVTOOLS-017) until another entity ornullis selected, and the store MUST keep working. - DEVTOOLS-044 [ecs][test]:
setIncludeExcluded(true)MUST makeentitiesinclude excluded entities from the next snapshot, andfalseMUST remove them (DEVTOOLS-011). The default isoptions.includeExcluded ?? false. - DEVTOOLS-045 [ecs][test]: A listener that throws MUST NOT prevent the other listeners from being called or stop later notifications.
- DEVTOOLS-046 [ecs][test]: Work MUST NOT scale with the number of listeners: with 10 listeners subscribed, one batch of changes MUST rebuild the snapshot once (observable as
versionincreasing by one, andgetSnapshot()called by each listener returning the same object).
DEVTOOLS-F04 Inspector panel (M)¶
<WorldInspector world? options? className? /> renders the list, filter, and detail. world defaults to useWorld(). options are the store options (DEVTOOLS-032, DEVTOOLS-044). The component owns one store per mount and disposes it on unmount.
- DEVTOOLS-050 [ux][test]: The panel MUST render one list item per entity in the snapshot, showing the label and one badge per registered trait name and pair relation name, plus
+N unregisteredtext whenunregisteredCount > 0. The list is a labelled region ("Entities") usingIteminside aScrollArea. - DEVTOOLS-051 [ux][test]: With no entities the panel MUST show an
Emptystate with the text "No entities". With no selection the detail area MUST show "Select an entity". - DEVTOOLS-052 [ux][test]: A filter
Input(label "Filter entities") MUST filter the list case-insensitively by label or trait or relation name, and the panel MUST show the text "Showing N of M". Clearing the input shows all. The filter is view state only: it does not change the store. - DEVTOOLS-053 [ux][test]: Activating an item (click, Enter, or Space on the focused item) MUST select it:
aria-current="true"on that item, and the detail MUST show the label as a heading, then the sections Traits, Pairs, and Unregistered. Empty sections MUST be hidden. The Unregistered section MUST be aCollapsible, collapsed by default. - DEVTOOLS-054 [ux][test]: Each Traits row MUST show the trait name, the kind as text (exactly
tag,soa, oraos), and thetextvalue. Each Pairs row MUST showRelation → target labeland thetextvalue. Unregistered rows MUST showtrait #<id>and the kind, and no value. - DEVTOOLS-055 [ux][test]: Values MUST be rendered as text only: a string value such as
<img src=x onerror=alert(1)>MUST appear as literal text, and no HTML MUST be interpolated. - DEVTOOLS-056 [ux][test]: Keyboard: every control (filter, excluded toggle, items, section triggers) MUST be reachable by Tab and operable by Enter or Space. ArrowDown and ArrowUp on a focused item MUST move focus to the next or previous visible item, and MUST NOT select it until Enter or Space.
- DEVTOOLS-057 [ux][test]: A
Togglelabelled "Show excluded" (aria-pressed) MUST callsetIncludeExcluded. Excluded entities MUST carry anexcludedbadge. It is off by default. - DEVTOOLS-058 [ux][test]: The panel MUST be live: with a real world in
WorldProvider, spawning an entity with a registered trait adds an item, andentity.set(Trait, …)on the selected entity updates the detail text, without user action, in the next flush. The test drives time through the injectedscheduleandtimer. - DEVTOOLS-059 [ux][test]: If the selected entity is destroyed, the detail MUST show the text "Entity
- DEVTOOLS-060 [ux][test]: Unmounting MUST dispose the store (DEVTOOLS-033): after unmount no store listener is called and no further updates are scheduled. Mounting under React StrictMode MUST NOT leave a second live store.
- DEVTOOLS-061 [ux][test]: Information MUST NOT be conveyed by colour alone: kinds,
excluded,alive/destroyedstate, and the selection are all text or ARIA attributes. - DEVTOOLS-062 [ux][manual]: The panel MUST use only
@workspace/uicomponents and its Tailwind tokens (no new dependencies, no custom CSS file), and MUST work in a side panel from 320 px wide, with long values wrapping (not overflowing) and the list and detail scrolling independently. Verify with the Acceptance checklist (A1). - DEVTOOLS-063 [ux][manual]: Text contrast MUST meet WCAG AA in the light and dark themes of the template. Verify with the Acceptance checklist (A2).
- DEVTOOLS-064 [ux][manual]: With the panel open in
apps/demo-dnd(under 50 combatants, 12×12 grid) the UI MUST stay at 60 FPS during a full monster turn (demo brief §6). Verify with the Acceptance checklist (A3). - DEVTOOLS-065 [ux][test]: The package MUST NOT render its own open/close button or register a global hotkey. The host app owns opening, closing, and layout (the panel is mounted or not).
DEVTOOLS-F05 Package contract and isolation (S)¶
- DEVTOOLS-070 [ecs][test]: Importing the package MUST NOT have side effects. The only module-level state MUST be
WeakMap<World, …>registries (DEVTOOLS-004), so two worlds, and two stores of one world, are independent. - DEVTOOLS-071 [ecs][test]: Two stores on the same world MUST both stay correct, and disposing one MUST NOT remove the other's observers or change tracking (observers are the store's own callbacks, and
onChangetracking stays on while any callback remains, L7). - DEVTOOLS-072 [ecs][test]: The store MUST NOT disturb app observers: an app's own
world.onAdd,onRemove,onChange, andonQueryAddcallbacks, andChanged(T)queries, MUST give the same results with and without a subscribed store, and after it is disposed. - DEVTOOLS-073 [ux][manual]:
package.jsonMUST declarekootaandreactaspeerDependencies,kootaalso as an exact-versiondevDependency, and@workspace/uias a workspace dependency (OQ2), with"sideEffects": falseand no other runtime dependencies. Verify with the Acceptance checklist (A4). - DEVTOOLS-074 [ux][manual]: The README MUST state: (a) traits and relations must be registered (
registerNames) to be named and shown with values, others aretrait #<id>without a value; (b) a trait-less entity and an AoS in-place mutation are detected by the poll only (default 500 ms), and creation or destruction of entities with traits is live; © selecting an entity makes koota track changes for its registered traits (L7), which has a small cost inupdateEach; (d) excluded entities (prefabs and koota's hidden world entity) are hidden by default; (e) afterworld.reset(), callstore.refresh(); (f) the panel is read-only. Verify with the Acceptance checklist (A5).
DEVTOOLS-F06 demo-dnd scenario (S, test-only)¶
Uses traits like the demo's: Name (data), Health, ArmorClass, Hero, Monster, Defeated (tags), and HasEffect (a relation with data { turns }) with Poisoned as a target entity, plus IsExcluded on template entities (so ecs-prefab is not needed).
- DEVTOOLS-080 [ux][test]: A world with 3 heroes and 3 monsters, a label function that reads
Name, and 2 template entities withIsExcluded: the panel MUST list exactly the 6 combatants by name, with their registered trait badges (e.g.Health,Hero), and no templates. With "Show excluded" on, it MUST list 8 (plus the world entity, which is not asserted by name, L1). - DEVTOOLS-081 [ux][test]: Applying
HasEffect(Poisoned)with{ turns: 3 }to a selected combatant MUST add a Pairs row "HasEffect → Poisoned" with the text{ turns: 3 }; settingturnsto 2 MUST update that text; removing the pair MUST remove the row and the list badge (DEVTOOLS-014, DEVTOOLS-058). - DEVTOOLS-082 [ux][test]: Adding the
Defeatedtag and settingHealthto{ hp: 0 }on a combatant MUST show theDefeatedbadge in the list and the newHealthtext in the detail, without user action (DEVTOOLS-058). - DEVTOOLS-083 [ux][test]: Destroying the selected combatant MUST show the destroyed message (DEVTOOLS-059) and the list MUST drop it, even when the combatant had only unregistered traits (DEVTOOLS-034).
API sketch¶
import type { Entity, Relation, Trait, World } from 'koota';
export type TraitKind = 'tag' | 'soa' | 'aos';
// Registry (per world). Keys become names; throws on invalid input (DEVTOOLS-003).
export function registerNames(world: World, names: Record<string, Trait | Relation<Trait>>): void;
export function getName(world: World, traitOrRelation: Trait | Relation<Trait>): string | undefined;
export function setEntityLabel(world: World, label: ((entity: Entity) => string | undefined) | undefined): void;
// Snapshots (headless, pure reads).
export interface EntitySummary {
entity: Entity;
label: string;
excluded: boolean;
traits: readonly string[]; // registered trait names
pairs: readonly string[]; // registered relation names with at least one target
unregisteredCount: number;
}
export interface TraitEntry { name: string; kind: TraitKind; value: unknown; text: string }
export interface PairEntry { relation: string; target: Entity; targetLabel: string; value: unknown; text: string }
export interface UnregisteredEntry { id: number; kind: TraitKind } // no value (DEVTOOLS-016)
export interface EntityDetail {
entity: Entity;
label: string;
alive: boolean;
excluded: boolean;
traits: readonly TraitEntry[];
pairs: readonly PairEntry[];
unregistered: readonly UnregisteredEntry[];
}
export function listEntities(world: World, options?: { includeExcluded?: boolean }): EntitySummary[];
export function inspectEntity(world: World, entity: Entity): EntityDetail;
export function formatValue(value: unknown): string;
// Live store (shaped for useSyncExternalStore).
export interface WorldSnapshot {
version: number;
includeExcluded: boolean;
entities: readonly EntitySummary[];
selected: EntityDetail | null;
}
export interface WorldStoreOptions {
pollMs?: number; // default 500, 0 disables (DEVTOOLS-032)
includeExcluded?: boolean; // default false
schedule?: (flush: () => void) => void; // default queueMicrotask
timer?: { setInterval(cb: () => void, ms: number): unknown; clearInterval(handle: unknown): void };
}
export interface WorldStore {
subscribe(listener: () => void): () => void;
getSnapshot(): WorldSnapshot;
select(entity: Entity | null): void;
setIncludeExcluded(include: boolean): void;
refresh(): void; // synchronous, resubscribes everything (L9)
dispose(): void;
}
export function createWorldStore(world: World, options?: WorldStoreOptions): WorldStore;
// React panel (built on @workspace/ui).
export interface WorldInspectorProps {
world?: World; // default: useWorld()
options?: WorldStoreOptions;
className?: string;
}
export function WorldInspector(props: WorldInspectorProps): JSX.Element;
Usage for the demo (docs only):
registerNames(world, { Name, Health, ArmorClass, Hero, Monster, Defeated, HasEffect });
setEntityLabel(world, (e) => e.get(Name)?.value);
// in the app: {showDevtools && <WorldInspector />}
Notes for implementation:
- Observers: for each trait in
world.traits,onAddandonRemove(L2, L3); relations and trait-backing traits are covered because the pair-backing trait is itself inworld.traits. Diff the observed set againstworld.traitson each poll and onrefresh(). - Snapshot building:
world.entitiesfiltered byhas(IsExcluded), then for each entity the loopfor t of world.traits: entity.has(t)(L4), split into registered and unregistered. select(entity)attachesonChangefor the entity's registered traits that are inworld.traits, and for registered relations for which the entity has a pair (DEVTOOLS-041). Every attach has a matching detach.- Keep one
dirtyflag and one scheduled flush (DEVTOOLS-039). The flush rebuilds, compares by text (DEVTOOLS-031), and notifies only on a difference. formatValueis its own module with no koota import.- First task: a runtime spike for the unverified items (L1, L2,
entity.get(relation(target))), written as the canary tests (DEVTOOLS-013, DEVTOOLS-034, DEVTOOLS-014).
Differences from flecs¶
- The Explorer is a separate web app talking to a REST server. This is an in-process React panel on a world object, and it can only read what koota's public API exposes.
- flecs entities and components have names and paths. koota has none, so the app registers names (DEVTOOLS-001) and may give a label function (DEVTOOLS-005). Unregistered traits show as
trait #<id>without a value. - The Explorer streams updates from the server. Here changes come from koota observers, with a poll for what koota gives no event for (trait-less entities, AoS in-place mutation, world reset, new traits).
- Pairs are shown as a flat list of
Relation → targetrows (DEVTOOLS-014). There is no hierarchy tree and no relations graph. - Prefabs and the hidden world entity are both just excluded entities here (L1). flecs shows prefabs as a distinct kind.
- The panel is read-only. The Explorer can edit values and run queries.
Out of scope¶
- Relations graph, hierarchy tree, entity paths and names (the
Nametrait is planned forecs-relations). - A query view, query editing, and any filter by query (the list filter in DEVTOOLS-052 is text only).
- System timings, schedule view, and any dependency on
ecs-schedule. (The schedule's state trait can be registered by the app like any trait.) ecs-serializeuse, export, import, and snapshot download.- Editing values, adding or removing traits, spawning or destroying entities from the panel (L11).
- Virtualized lists. v0 renders every entity, for worlds of up to a few hundred entities.
- Showing values of unregistered traits (OQ4), and hiding relation-backing traits (L5; possible if koota adds a public marker).
- A built-in toggle button, hotkey, floating window, or persistence of panel state (DEVTOOLS-065).
- Multiple worlds in one panel and world switching.
- Entity creation and destruction events, a koota
resetevent, and anentity.traits()method. These are upstream candidates (see Open questions). - R3F or 3D overlays; plain 2D UI only.
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 with all defaults; OQ2 is explicitly resolved, and the rest stay as recorded defaults.
- ecs-prefab OQ6 (relation-backed traits in
world.traits): resolved for devtools, still open for ecs-prefab. koota's public API cannot tell the trait behind a relation from a plain trait (L5), and this spec was written to not need it: a trait is named only if the app registered it, everything else is an unregistered trait whose value is never read (DEVTOOLS-016), and pairs are read only through registeredRelationobjects (DEVTOOLS-014). The relation-backing traits therefore show astrait #<id>rows in the Unregistered section (collapsed by default, DEVTOOLS-053), which is harmless noise. This does not helpecs-prefab, whose copy loop must skip relation-backed traits and has no registry. The OQ6 decision (spike, then a package-owned list of plain prefab traits written bycreatePrefab) stays in ecs-prefab and must be made there before its implementation. Recommended upstream candidate (benefits both packages): a publicisRelationTrait(trait)or aRelation→ backing trait accessor in koota. If it lands, devtools can hide the backing traits (new requirement), andecs-prefabcan drop its fallback. - Dependencies. RESOLVED (approved by the user). The package needs
react(peer) and@workspace/ui(workspace dependency, bundled UI) on top of thekootapeer that package-rules allows ("no other runtime dependencies"). Decision:reactandkootaas peers,@workspace/uias aworkspace:*dependency, since concept §6 specifies a panel built on it. This is the approval AGENTS.md requires for new dependencies, and it covers only these two. Rejected alternative: ship the headless layers (F01 to F03) as the package and the React panel insideapps/weband the demos. - Excluded entities and the world entity. Default: all excluded entities are hidden, and a toggle shows them (DEVTOOLS-011, DEVTOOLS-057). The world entity cannot be told apart from prefabs (L1), so with the toggle on it appears as a plain excluded entity with its
#idlabel. Alternative: hide the world entity always by skipping the excluded entity with no registered traits and the lowest id (a guess, not recommended), or ask koota for a public accessor. - Values of unregistered traits. Default: not read (DEVTOOLS-016), because the traits behind relations return pair data of an unspecified target through
get, and showing it would mislead. Alternative: read and format them, labelledtrait #<id>. - Poll default. Default: 500 ms, and
pollMs: 0disables it (DEVTOOLS-032). It exists only for what koota gives no event for (L2, L3, L7, L9). A shorter interval shows trait-less entities and AoS in-place mutations sooner, and costs oneworld.entitiescopy and one selected-entity read per tick. Alternative: no poll, and apps callstore.refresh()at turn boundaries (the demo can do this in anOnStore-like phase). - Detecting
world.reset(). There is no public event (L9). Default: the poll notices that an observed trait leftworld.traits(DEVTOOLS-038), and apps may callrefresh(). This misses a reset after which every observed trait is registered again within one poll interval, untilrefresh()or the next event for a new trait. Alternative: ask koota for a public reset subscription (upstream candidate). - Registry style. Default:
registerNames(world, { Name: Trait }), which fitsimport * as traits, and which throws on non-trait exports (DEVTOOLS-003). An app that passes a module namespace with other exports must filter it first. Alternative: ignore non-trait values silently (hides typos) or one call per trait. - Entity labels. Default: an app function (DEVTOOLS-005), with
#<id>as fallback. Onceecs-relationshas aNametrait, the default could read it. That would be a new requirement and not a dependency in v0. - Change tracking cost (L7). Default:
onChangeonly for the selected entity's registered traits and relations (DEVTOOLS-040, DEVTOOLS-041). Alternative: noonChangeat all, with values refreshed by the poll, which avoids enabling tracking in the app at the cost of up to one poll interval of lag. Decide after the spike ifupdateEachoverhead matters in the demo. - Unverified koota behaviour. To be confirmed by the canary tests in the first implementation work item: the world entity in
world.entities(DEVTOOLS-013),onRemovefiring per trait on destroy (DEVTOOLS-034),entity.get(relation(target))returning pair data (DEVTOOLS-014), andonChange(relation)firing with(entity, target)(DEVTOOLS-041). If a canary fails, the affected requirement changes, and L-items are updated. - Upstream candidates (not v0 work). A public
isRelationTrait, an entity lifecycle event (oronQueryAdd([])support for trait-less entities), a reset subscription,entity.traits(), and trait names. All would remove poll or registry parts of this package. - Manual verification. RESOLVED (approved by the user).
[manual]requirements are verified by the Acceptance checklist below and not by an ad-hoc check inapps/demo-dnd. A5 and A4 read files in the package; A1 to A3 need the panel mounted inapps/demo-dnd.
Acceptance checklist¶
A human runs these after the package work items are done. Each box covers one [manual] requirement. A1 to A3 need the panel mounted in apps/demo-dnd by the host app (DEVTOOLS-065), with the usage snippet above (registerNames, setEntityLabel) and a host-owned open/close control. If the demo has no panel yet, that is a demo work item, and A1 to A3 wait for it.
Setup for A1 to A3: pnpm install, then pnpm --filter demo-dnd dev, and open the printed local URL in Chrome or Edge. Start an encounter with at least 3 heroes and 3 monsters, and open the panel.
- A1 (DEVTOOLS-062): layout, components, narrow width.
- In devtools, set the viewport width to 320 px (device toolbar, responsive mode, width 320) and keep the panel open.
- Select an entity with a long value. If none has one, set a combatant name or an effect field to a string of more than 100 characters in the app. Expected: the value wraps onto several lines; there is no horizontal scrollbar on the panel or on the page because of it.
- Use an encounter with enough entities to fill the list, with one entity selected. Expected: scrolling the list does not scroll the detail, and scrolling the detail does not scroll the list.
- Run
grep -rn "import" packages/ecs-devtools/src --include=*.tsx | grep -v "@workspace/ui\|koota\|react\|\./"andfind packages/ecs-devtools -name "*.css" -not -path "*/node_modules/*". Expected: the grep lists no imports of UI libraries other than@workspace/ui,koota,react, and package-local modules; thefindprints nothing. - Run
grep -n "dependencies" -A6 packages/ecs-devtools/package.json. Expected: no UI or styling library beyond the ones listed in A4. - A2 (DEVTOOLS-063): contrast, light and dark.
- With the panel open and an entity selected, open devtools, Lighthouse, and run an Accessibility audit with the page in light mode (press
dto toggle the theme, with focus outside any input; the template's theme provider handles this). Expected: no "Background and foreground colors do not have a sufficient contrast ratio" finding for any element inside the panel. - Press
dto switch to dark mode and run the audit again. Expected: the same, no contrast finding inside the panel. - In both themes, check by eye the badges (including
excluded), thetag/soa/aoskind text, muted text ("Showing N of M", "Select an entity"), and the selected item. Expected: all text is readable. Selection and state remain visible without colour (DEVTOOLS-061). - A3 (DEVTOOLS-064): 60 FPS during a monster turn.
- Start an encounter near the upper bound of the brief (about 40 to 49 combatants on the 12×12 grid), open the panel, and select one combatant so change tracking is on.
- In Chrome devtools, Performance tab, enable "Screenshots", click Record, play one complete monster turn (every monster acts), then stop. Expected: the FPS chart stays at 60 with no red bars. No frame in the Frames track is longer than 16.7 ms (no dropped or long frames) during the turn.
- In the same recording, look for tasks from the panel: the store flush and the snapshot rebuild. Expected: no Long Task (over 50 ms) from the panel. The store notifies about once per batch (DEVTOOLS-039), not once per change.
- Repeat steps 2 and 3 with the panel closed. Expected: the panel adds no visible frame drops compared with the closed run.
- A4 (DEVTOOLS-073): package.json dependencies.
- Run
cat packages/ecs-devtools/package.json. Expected:peerDependencieslistskootaandreact;devDependencieshaskootaat an exact version (orcatalog:whose entry inpnpm-workspace.yamlis an exact version);dependenciesholds only"@workspace/ui": "workspace:*";"sideEffects": falseis present; there are no other runtime dependencies. - Run
pnpm turbo run typecheck lint test --filter=@workspace/ecs-devtools. Expected: it passes, which shows the declared dependencies are enough. - A5 (DEVTOOLS-074): README content.
- Run
cat packages/ecs-devtools/README.md. Expected: it states all of (a) to (f) of DEVTOOLS-074. Tick off each one as found:- (a) traits and relations must be registered with
registerNamesto be named and shown with values; others show astrait #<id>without a value. - (b) a trait-less entity and an AoS in-place mutation are detected only by the poll (default 500 ms); creation or destruction of entities with traits is live.
- © selecting an entity makes koota track changes for its registered traits, with a small cost in
updateEach. - (d) excluded entities (prefabs and koota's hidden world entity) are hidden by default.
- (e) after
world.reset(), callstore.refresh(). - (f) the panel is read-only.
- (a) traits and relations must be registered with