diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index aac27b7d2..2f4f9827b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -50,5 +50,8 @@ jobs:
- name: Build (every package, topological)
run: pnpm -r run build
+ - name: API docs (every package that defines them; warnings fail)
+ run: pnpm -r run docs
+
- name: Test (each package runs its own complete suite)
run: pnpm -r run test
diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml
index 8585882d7..aab571bb6 100644
--- a/.github/workflows/deploy-docs.yml
+++ b/.github/workflows/deploy-docs.yml
@@ -31,28 +31,12 @@ jobs:
- run: pnpm install --frozen-lockfile
- - run: pnpm run build
- working-directory: packages/data
+ - run: pnpm -r run build
- - run: cp -r packages/data/dist packages/data/docs/dist
+ - run: pnpm -r run docs
- - run: pnpm --filter @adobe/data-sync build
-
- - run: pnpm --filter @adobe/data-lit build
-
- - run: pnpm --filter @adobe/data-gpu build
-
- - run: pnpm --filter data-p2p-tictactoe build
- env:
- CI: true
-
- - run: cp -r packages/data-p2p-tictactoe/dist packages/data/docs/p2p-tictactoe
-
- - run: pnpm --filter data-gpu-samples build
- env:
- CI: true
-
- - run: cp -r packages/data-gpu-samples/dist packages/data/docs/gpu-samples
+ # Each package's package.json `pages` field says what it publishes where.
+ - run: node scripts/collect-pages.mjs site
- uses: actions/configure-pages@v5
with:
@@ -60,7 +44,7 @@ jobs:
- uses: actions/upload-pages-artifact@v3
with:
- path: packages/data/docs
+ path: site
deploy:
needs: build
diff --git a/.gitignore b/.gitignore
index 9e20317d4..039c012cb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -47,6 +47,7 @@ source.txt
# Generated Docs (typedoc output)
docs/api
packages/data/docs/api
+packages/ecs/docs
packages/data/references
# Playwright MCP capture artifacts
@@ -54,3 +55,4 @@ packages/data/references
# Vitest browser attachments and failure screenshots
.vitest/
+/site/
diff --git a/CLAUDE.md b/CLAUDE.md
index 6f1c37349..15ee227d6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -27,6 +27,12 @@ Standard script names a package may define:
that the uniform pipeline does not call.
- **`lint`** — lint the package.
- **`dev`** — local dev/watch.
+- **`docs`** — generate the package's documentation (e.g. typedoc). What a
+ package publishes to the GitHub Pages site is its `pages` field: a list of
+ `{ "from", "to" }`, a folder of the package and its path on the site (`"."`
+ for the root). The docs workflow runs `pnpm -r run build` and
+ `pnpm -r run docs`, then `scripts/collect-pages.mjs` copies every package's
+ `pages`, so publishing never names a package.
Do **not** add a `publish-public` (or similar) script. Publishing is governed
*only* by the `private` field: `pnpm -r publish` publishes every package with
diff --git a/packages/data-gpu-samples/package.json b/packages/data-gpu-samples/package.json
index 0eb8c95d1..b6811ba77 100644
--- a/packages/data-gpu-samples/package.json
+++ b/packages/data-gpu-samples/package.json
@@ -4,6 +4,9 @@
"description": "WebGPU samples built on @adobe/data-gpu",
"type": "module",
"private": true,
+ "pages": [
+ { "from": "dist", "to": "gpu-samples" }
+ ],
"scripts": {
"predev": "pnpm --filter @adobe/data-gpu build",
"dev": "vite",
diff --git a/packages/data-gpu-samples/vite.config.ts b/packages/data-gpu-samples/vite.config.ts
index 11e224a87..e84a5469a 100644
--- a/packages/data-gpu-samples/vite.config.ts
+++ b/packages/data-gpu-samples/vite.config.ts
@@ -14,9 +14,9 @@ export default defineConfig({
},
},
root: ".",
- // In production (GitHub Pages) the app lives at /data/graphics-samples/.
+ // In production (GitHub Pages) the app lives at /data/gpu-samples/, its `pages` target.
// The dev server ignores this — it always serves from /.
- base: process.env.CI ? "/data/graphics-samples/" : "/",
+ base: process.env.CI ? "/data/gpu-samples/" : "/",
build: { outDir: "dist" },
server: { port: 3008, open: false },
});
diff --git a/packages/data-p2p-tictactoe/package.json b/packages/data-p2p-tictactoe/package.json
index 161963532..0c06e3911 100644
--- a/packages/data-p2p-tictactoe/package.json
+++ b/packages/data-p2p-tictactoe/package.json
@@ -4,6 +4,9 @@
"description": "Serverless P2P tic-tac-toe — WebRTC DataChannel + @adobe/data-sync",
"type": "module",
"private": true,
+ "pages": [
+ { "from": "dist", "to": "p2p-tictactoe" }
+ ],
"scripts": {
"dev": "vite",
"build": "vite build",
diff --git a/packages/data/docs/index.html b/packages/data/docs/index.html
index c9bc838ed..f27563489 100644
--- a/packages/data/docs/index.html
+++ b/packages/data/docs/index.html
@@ -16,6 +16,7 @@
@adobe/data
diff --git a/packages/data/package.json b/packages/data/package.json
index 3e640844d..bae33f235 100644
--- a/packages/data/package.json
+++ b/packages/data/package.json
@@ -5,6 +5,10 @@
"type": "module",
"sideEffects": false,
"private": false,
+ "pages": [
+ { "from": "docs", "to": "." },
+ { "from": "dist", "to": "dist" }
+ ],
"publishConfig": {
"registry": "https://registry.npmjs.org/",
"access": "public"
@@ -19,8 +23,8 @@
"scripts": {
"build": "pnpm build-assembly && run-p build:*",
"build:code": "tsc -b",
- "build:apidocs": "typedoc",
"typecheck": "pnpm build-assembly && tsc -b",
+ "docs": "typedoc",
"build-assembly": "run-p asbuild:release",
"clean": "rm -rf dist build node_modules",
"clean-cache": "rm -rf node_modules/.cache",
diff --git a/packages/ecs/CLAUDE.md b/packages/ecs/CLAUDE.md
index 91009ef66..4e2f63b05 100644
--- a/packages/ecs/CLAUDE.md
+++ b/packages/ecs/CLAUDE.md
@@ -25,7 +25,8 @@ Humans own the interface and agents own the implementation: agents don't add to
It's a project of its own: nothing in it imports from outside it. Each type exists once, there.
- **`src/implementation`** holds `createWorld` and everything behind it, the kernel compiler included. It imports
types and the registry's read side from `src/interface`.
-- **`src/samples`** holds one folder per sample, its page included; `pnpm sample` serves them.
+- **`src/samples`** holds one folder per sample, its page included. `pnpm dev` (or `pnpm sample`, without
+ the type-check watch) serves them, with a sample browser at the root that has a tab for each.
- `src/index.ts` exports `src/interface`, and `World` as a value too, whose `create` and `join` are the
implementation's, so `import { World } from "@adobe/ecs"` gives consumers the type, `World.create` and `World.join`,
the only entry points. Samples and the guide use them; tests and benchmarks may take `createWorld` from
@@ -44,6 +45,12 @@ when a measured win conflicts with a style or design preference, take the win an
row as low as it can be, and allocate nothing on those paths: reuse arrays, buffers and objects, share read-only
empties, and avoid closures, spreads and awaits there. Measure changes with `pnpm perf` against the recorded baseline.
+## Pull requests
+
+Each PR costs a review, a merge and a publish, so keep one open PR at a time. Add new work and fixes to it as more
+commits; never open stacked or parallel PRs. Keep it rebased on main, with this package's version one above main's.
+Bump it by hand in `package.json`; never run the workspace `bump` script, which bumps packages this PR didn't change.
+
## Memory
Decisions about working on this package live in `memory.md`; add new ones there.
diff --git a/packages/ecs/README.md b/packages/ecs/README.md
index 619a1ed35..ec7e5e7b5 100644
--- a/packages/ecs/README.md
+++ b/packages/ecs/README.md
@@ -1,5 +1,54 @@
# @adobe/ecs
-A data-oriented entity component system. Systems run on the main thread, on worker threads, as WASM kernels or on the GPU.
+A high-performance, multithreaded Entity Component System for web-based applications, simulations and games.
-See the [guide](src/guide/guide.md) for how to use it.
+## Features
+
+- **Linear, gapless structure-of-arrays component storage**
+ - Fixed-length values (numbers, tuples, structs) in a `SharedArrayBuffer`
+ - Shared across the main thread and all workers
+ - Read by WASM kernels without copying
+ - WebGPU-compatible struct layout for fast uploads
+ - WebGPU storage for GPU system kernels
+ - Synced back to shared memory only when the CPU reads it
+ - Strings in shared memory, readable on every thread
+ - Objects and other variable-length values on the thread that owns them
+- **Declarations**
+ - Components and resources declared once, as TypeScript types; the build registers them
+ - Named types such as `F32`, `U32` and your own `Schema.Type`s give storage precision, width and bounds
+- **Systems**
+ - Plain TypeScript bodies with declared reads and writes
+ - Kernels compiled and checked at build time to optimized JavaScript, WASM and WGSL
+ - Balanced scheduling across the main thread, workers and the GPU
+ - Typed input, with its schema generated from the TypeScript type
+ - Render systems that draw straight from component storage
+ - Observer systems for reading state into a UI
+- **Changes**
+ - Per-component added and changed ticks: an O(1) check per entity, with each change seen exactly once
+ - Filters that skip unchanged chunks and 64-row blocks
+ - Structural changes batched at sync points, so memory never moves while systems run
+ - Rules, finalizers and traits for derived components, cleanup and references
+- **Queries**
+ - Hash, unique, sorted and keyless indexes, all updated incrementally
+ - Hierarchy traversal that reruns only below what changed
+- **Threads**
+ - Worlds shared with other threads through a `MessagePort` or worker
+ - Joined threads run their own systems and observers, and lend their workers to the pool
+- **Runtime**
+ - Kernels allocate nothing per row; commands, lookups and reports reuse their buffers
+ - Errors reported as data, in each run's report
+
+## Setup
+
+```ts
+// vite.config.ts
+import { Compiler } from "@adobe/ecs/compiler";
+export default defineConfig({ plugins: [Compiler.plugin()] });
+```
+
+A library builds with `Compiler.plugin({ library: true })`, so its build ships the registrations its declarations give.
+
+## Guide
+
+See the [guide](src/guide/guide.md) for how to use it, and the [API reference](https://adobe.github.io/data/ecs/) for
+every type and function.
diff --git a/packages/ecs/design.md b/packages/ecs/design.md
index da7d33e89..aa0ee28a5 100644
--- a/packages/ecs/design.md
+++ b/packages/ecs/design.md
@@ -27,8 +27,9 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+.
- Structure of Arrays component storage.
- Numeric types, tuples and structs live in linear memory backed by `SharedArrayBuffer`, so workers can read and write them.
+- **A union of number constants** (`0 | 1 | 2`, or an `as const` object's values) is a number: an integer column of the narrowest of u32 and i32 holding every constant, else f64, as with `-0`, which integers can't hold. A resource of one without a default starts at its first constant. A union with `null`, as `number | null` is, isn't a struct: it's stored as an object.
- **Structs** (numbers, booleans, entities, fixed-length tuples and structs of those) have a fixed size and live in shared memory. **Strings** (any string, a union of string literals, either with `null`) live in shared memory too, so every thread reads them, and have no thread: a declaration giving one a `thread` is rejected, in types and at registration, and a string places no system. **Objects** (everything else: variable-length arrays, plain objects, DOM nodes, GPU objects, functions) live on one logical thread, `world` unless declared otherwise ([`threads.md`](threads.md)).
- - **A string's storage:** the world's thread holds each value as it is, in a column of values, and reads and writes it there, as it does an object. While a joined thread may read strings, from the first `share` until the last thread sharing is gone, the world also keeps a record of each value in its memory, which a column of 4-byte handles names, 0 for `null` (`strings.ts`): its length in UTF-16 code units, a generation and its code units, immutable. A write makes a new record and lets the old one go; a record let go is freed at the end of the second sync point after, which no joined reader outlives: a task ends with its run, and a tail or an observer post, with its snapshot, before the second sync point after the one it reads. Two readers outlive that, so they hold records of their own: a joined thread's finalizer reads the strings its stops held from copies in the stop list, let go once the thread acknowledged them, and an observer system reading a string resource posts as row systems do, since a post holding copies of its resources is waited for by nothing. Records up to 512 bytes are carved from slabs by power-of-two size class, larger ones are the arena's blocks. A joined thread decodes a record once and keeps its text by handle, while the record's generation is the same. Without shared memory, which no other thread reads, strings are held only as values.
+ - **A string's storage:** the world's thread holds each value as it is, in a column of values, and reads and writes it there, as it does an object. While a joined thread may read strings, from the first `share` until the last thread sharing is gone, the world also keeps a record of each value in its memory, which a column of 4-byte handles names, 0 for `null` (`strings.ts`): its length in UTF-16 code units, a generation and its code units, immutable. A write makes a new record and lets the old one go; a record let go is freed at the end of the second sync point after, which no joined reader outlives: a task ends with its run, and a tail or an observer post, with its snapshot, before the second sync point after the one it reads. Two readers outlive that, so they hold records of their own: a joined thread's finalizer reads the strings its stops held, and the string resources it reads, from copies in the stop list, as it reads its linear resources, so every resource as of before its stops' sync point applied, let go once the thread acknowledged them, and an observer system reading a string resource posts as row systems do, since a post holding copies of its resources is waited for by nothing. Records up to 512 bytes are carved from slabs by power-of-two size class, larger ones are the arena's blocks. A joined thread decodes a record once and keeps its text by handle, while the record's generation is the same. Without shared memory, which no other thread reads, strings are held only as values.
- **What reads and writes them:** any system reads strings, on any thread. Only the world's thread writes them: a system placed on a joined thread that writes one is rejected there, and a command on a joined thread gives one only its start. Kernels don't read them yet, by type and check. An index keyed by strings is kept on the world's thread, by their values as objects' are, so it's looked up only there.
- Memory only reallocates at sync points. Structure is frozen between sync points, so workers never see memory move mid-phase.
- Removed rows stay in place until Apply. At Apply the last row is swap-moved into the hole and the entity lookup table is updated.
@@ -36,16 +37,16 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+.
## Execution
- **Systems are designed to run on workers.** That is a primary constraint, not an add-on.
-- **A system's declared read/write sets drive both ordering and parallelism.**
+- **A system's read/write sets, which the build infers from its body, drive both ordering and parallelism.**
- **Systems that touch main-thread-only components are pinned to the main thread.**
- **Structural changes become visible only at sync points,** whether they come from the main thread or from workers.
-- **Component values are immutable values,** deeply, by type convention rather than freezing. A system reads a value and writes a whole new one (`row[position] = [x + vx, y + vy, z + vz]`); values are never edited in place. This is value semantics, as in shading languages: no value aliases another, so the compiler is free to lower a whole-value write to per-leaf stores, as a shader lowers a struct assignment. System source is ordinary TypeScript, type-checked normally.
-- **Kernel systems (`kernel: "cpu"` or `"gpu"`) are compiled at build time.** A Vite/Rolldown plugin compiles them in its `transform` hook, after TS types are stripped and before minification, so names are never mangled and nothing is parsed at runtime.
+- **Component values are immutable values,** deeply, by type convention rather than freezing. A system reads a value and writes a whole new one (`row[position] = [x + vx, y + vy, z + vz]`); values are never edited in place. A body changing something inside a value through its row or resources (`row[k].a.b = 1`, `delete row[k].a`) fails the system's creation when the value is data, a struct or a string, which rows and resources give as copies, so the change would be lost; inside an object, such as a DOM node a binding sets properties of, it's a change in place, read and tracked by nothing, as a method changing one is (`resources[map].set(…)`): declare objects that change with readonly types (`ReadonlyMap`) and replace them whole where other systems must see the change. A start shared by every row and by a world's resource, such as one empty `Map` per world, is never changed for the same reason. This is value semantics, as in shading languages: no value aliases another, so the compiler is free to lower a whole-value write to per-leaf stores, as a shader lowers a struct assignment. System source is ordinary TypeScript, type-checked normally.
+- **Kernel systems are compiled at build time:** a system with components whose body is in the kernel subset, `kernel: true` or `"gpu"`. A Vite/Rolldown plugin compiles them in its `transform` hook, after TS types are stripped and before minification, so names are never mangled and nothing is parsed at runtime.
- It finds systems by resolving the import binding of the system factory.
- - From one parse it emits a compiled kernel (efficient JS; later WASM and WGSL) and the inferred read/write sets, which are checked against the declared signature both ways: a body can't use a key its declaration doesn't list, and a declaration can't list a key its body never uses, since that key would still narrow which entities match and hold back systems that could run alongside it. The same goes for a reduced resource the body never contributes to. Main-thread systems get the second check where their bodies index or destructure `row` and `context` only by module-scope keys, and only index or test lookup results; otherwise TypeScript's row and context types are the only check.
- - **The plugin is required for kernels.** Registering a kernel system that wasn't compiled throws.
- - **The plugin also gives keys ids** (`compiler/identify.ts`), in the same `transform`, after the kernel transform: each top-level `const`, `let` or `var` declarator, exported or not, initialized by a call of the global `Symbol`, and `export default Symbol(...)`, becomes `identify$(Symbol(...), "#")`, with a source map (`compiler/source-map.ts`). `` is the name of the nearest `package.json` above the module that has one and the module's path in that package, without a query, so a workspace-linked package names no directory outside it; without a named package, a dependency's package name and path inside `node_modules`, or the path from the root; never an absolute path; `#default` names a default export. A function appended to the module records the id in a `Map` under the global `Symbol.for("@adobe/ecs/ids@1")`, which the registry reads at registration, so the code imports nothing and nothing new is exported. Symbols made in functions, blocks, TypeScript namespaces, class fields or CommonJS, and modules where `Symbol` or `globalThis` is bound, aren't identified. In the same way, a top-level declarator initialized by a call of `System.create`, `System.render`, `Rule.create`, `Finalizer.create` or `Finalizer.changes`, with `System`, `Rule` or `Finalizer` imported from the package under any local name, and its `export default` form, gives the call's first argument, its declaration, a composite's list of members or a system's body alone, the id `"#"`: `identify$(declaration, id)` records it in the same map, and the constructor takes it out when it registers the item, so a declaration object reused elsewhere has no id. A kernel's call wrapped by the kernel transform is identified the same. The plugin adds itself to `worker.plugins`; a second instance in one bundle does nothing.
- - **The application compiles every kernel,** its dependencies' too, against the one runtime it bundles. A library ships its kernels as source in its `dist`, uncompiled and unminified, so their code stays in the kernel subset and its errors stay readable. Non-kernel systems run as written on the main thread, through accessor rows over the same storage.
+ - **Access is inferred** (`compiler/body-access.ts`, `compiler/inferred.ts`): for every `System.create`, `System.render`, `Finalizer.create` and `Finalizer.changes` call with its declaration written in place (or passed through a wrapping call) and its body a function in place or bound by name at module scope, the build reads what the body reads and writes of its row, reads, writes and reduces into of resources, reads of each lookup's rows and of its parent through `up`, by module-scope keys and namespaces' members (`Report.run`). It wraps the declaration as `access$(declaration, { components, resources, lookup, up, at })`, recording that in a global `WeakMap` under `Symbol.for("@adobe/ecs/access@1")`, which the constructor takes out and joins with the declaration into the lists the world takes (`interface/system/resolve.ts`): a row's reads and writes, the rest of `with` as `has`, a lookup's reads, `up`'s reads. A kernel compiles from the same lists. A key a body uses that its declaration doesn't require fails the build, so matching never changes unseen; TypeScript rejects it too, typing rows over `with`. Where the build can't follow a row, every member is taken as written, with a build warning naming the module by its path in its package, line and column; an observer's is taken as read, and where it can't follow a lookup's rows, all it requires is taken as read, without a warning, since that is what their declarations say; a body whose resources or `up` it can't follow fails at registration, since every resource would place it on every thread. An internal factory making declarations at runtime records its own access first, which the build's doesn't replace; the implementation's own systems use `createResolved` with lists already resolved.
+ - **The plugin is required for kernels and input.** Registering a kernel system that wasn't compiled throws, as does a system whose body takes input without the schema the plugin gives. `Compiler.plugin()` returns its two parts: input schemas, before TypeScript is stripped, and kernels and ids, after.
+ - **The plugin also gives keys ids** (`compiler/identify.ts`), in the same `transform`, after the kernel transform: each top-level `const`, `let` or `var` declarator, exported or not, initialized by a call of the global `Symbol`, and `export default Symbol(...)`, becomes `identify$(Symbol(...), "#")`, with a source map (`compiler/source-map.ts`). `` is the name and version of the nearest `package.json` above the module that has a name, `name@version`, or `name` alone without a version, and the module's path in that package, without a query, so a workspace-linked package names no directory outside it and copies of a package at different versions never share ids; for a dependency without one, its package name and path inside `node_modules`; never an absolute path; `#default` names a default export. A module outside both that has anything to identify fails the build, so an id never depends on the build's root. A function appended to the module records the id in a `Map` under the global `Symbol.for("@adobe/ecs/ids@1")`, which the registry reads at registration, so the code imports nothing and nothing new is exported. Symbols made in functions, blocks, TypeScript namespaces, class fields or CommonJS, and modules where `Symbol` or `globalThis` is bound, aren't identified. In the same way, a top-level declarator initialized by a call of `System.create`, `System.render`, `Rule.create`, `Finalizer.create` or `Finalizer.changes`, with `System`, `Rule` or `Finalizer` imported from the package under any local name, and its `export default` form, gives the call's first argument, its declaration, a composite's list of members or a system's body alone, the id `"#"`: `identify$(declaration, id)` records it in the same map, and the constructor takes it out when it registers the item, so a declaration object reused elsewhere has no id. A kernel's call wrapped by the kernel transform is identified the same. The plugin adds itself to `worker.plugins`, and sets `worker.format` to `"es"` where the config doesn't, since kernels in worker bundles are separate chunks: that applies to every worker the app bundles, its own included, which load as module workers. A second instance in one bundle does nothing. Ids identify keys across threads and reloads; nothing persists them, and persisted data will need names that survive refactors and versions.
+ - **The application compiles every kernel,** its dependencies' too, against the one runtime it bundles. A library ships its kernels as source in its `dist`, uncompiled and unminified, so their code stays in the kernel subset and its errors stay readable. It builds with Vite and `Compiler.plugin({ library: true })`, which runs only the pass before TypeScript is stripped, so its `dist` ships the registrations and input schemas its types give, which an application's build can't read from JavaScript, while its kernels stay source and its keys get their ids from the application's plugin. A library built by `tsc` alone, and declarations in `.d.ts` files, register nothing. Non-kernel systems run as written on the main thread, through accessor rows over the same storage.
- **Workers:**
- The plugin emits each kernel as its own chunk and attaches its `new URL(..., import.meta.url)` reference to the system. Served, a kernel's url is the dev server's `/@ecs-kernel/…` module, which a page and its workers import from it; served to node, as tests run, it's the kernel's module as a `data:` url, which a pool's `worker_threads` import without the server.
- On registration, workers `import()` each kernel and cache it by the world's index for its module's url, so new values of one kernel, as a module evaluated again makes, share one entry and workers' caches are bounded by code.
@@ -55,8 +56,8 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+.
- **`SharedArrayBuffer` requires cross-origin isolation** (COOP/COEP headers). Without it, every system runs on the main thread.
- **Subset errors** are reported at build time. An ESLint rule shares the same validator, so errors also show in the editor.
- **GPU systems.** The built-in `gpuDevice` resource, `GPUDevice | null`, is the one device the world's GPU storage lives on, for compute and render alike: GPU buffers belong to one device, so a second device means a second world. It's an ordinary resource, given as `World.create({ gpuDevice: device })` or set later by a main-thread system; the world never destroys it. After each run the world follows it: a new device gets the GPU storage and `kernel: "gpu"` systems, after what's ahead on the old device is read back; with null, GPU kernels run on the CPU again. Render systems run only while there's a device, as if their `is` listed `gpuDevice`. A lost device makes the world set `gpuDevice` to null, as its own sync point, so nothing fails: GPU kernels run on the CPU from the CPU's copies, render systems are skipped, and systems watching `changed: [gpuDevice]` see it; GPU writes not yet read back are lost with the device.
- - **`kernel: "gpu"` declares a GPU kernel.** It runs as a WGSL compute shader compiled from the same Program IR as WASM in a world with a device, and as a `"cpu"` kernel in one without, so it stays portable. Each rule is checked as early as it can be known: types rule out lookups, change filters and commands; the build rejects loops and anything that keeps the body from lowering to a Program; registration rejects f64 or byte values, in every world, since such a declaration can never hold.
- - **Render systems** (`System.render`) run once per run on the main thread in schedule order, read-only. They record into the run's encoder over the GPU copies of the components their accessors name, which are what they read, one table per matched archetype. `Gpu.wgsl(system)` returns their shaders' declarations: the world's bind group at the group they name and a function per component (`fn position(row: u32) -> vec3`), from schemas alone, so shaders can be written before a world exists. Pipelines take `Gpu.layout(device)`, one object per device, so they can be built before a world exists; bind groups and buffers stay the world's, so they can be replaced as storage grows.
+ - **`kernel: "gpu"` declares a GPU kernel.** It runs as a WGSL compute shader compiled from the same Program IR as WASM in a world with a device, and as a kernel on the pool in one without, so it stays portable. Each rule is checked as early as it can be known: types rule out lookups, change filters and commands; the build rejects loops and anything that keeps the body from lowering to a Program; registration rejects f64 or byte values, in every world, since such a declaration can never hold.
+ - **Render systems** (`System.render`) run once per run on the main thread in schedule order, read-only. They record into the run's encoder over the GPU copies of the components their accessors name, which are what they read, one table per matched archetype; with `order`, a sorted index, one table of every matching row in its order, whose accessors resolve an instance's position through an order block of slot and row to its archetype's columns. `Gpu.wgsl(system)` returns their shaders' declarations: the world's bind group at the group they name and a function per component (`fn position(row: u32) -> vec3`), from schemas alone, so shaders can be written before a world exists. Pipelines take `Gpu.layout(device)`, one object per device, so they can be built before a world exists; bind groups and buffers stay the world's, so they can be replaced as storage grows.
- **Residency:** a separate GPU heap holds copies of only the storages GPU systems use. Each copy tracks rows the CPU changed since its upload and rows the GPU wrote since its readback. GPU systems upload what the CPU changed first, in runs of the chunks it stamped since the last upload: the components GPU systems use keep ticks, and kernels writing them keep their chunk stamps. CPU systems read back what the GPU wrote first. A sync point reads back only archetypes whose existing rows it moves or writes (despawns, adds, removes); spawns only append rows, which are uploaded alone, and a copy that outgrows its block moves on the GPU. GPU-to-GPU chains and rendering never copy back.
- **Change filters:** a GPU kernel's writes stamp each written column once with the run's tick, and `changed` filters pass a whole column when its stamp is newer than their `lastRun`, so writes on the GPU cost nothing per row on the CPU.
- **Ordering:** a queue write lands before recorded but unsubmitted work, so recorded work is submitted before each upload. Each run's encoder is submitted at its end, before the sync point; `run` resolves then, not when the GPU finishes.
@@ -70,25 +71,31 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+.
- **`Changed` is conservative:** it never misses a change but may report a component whose value is unchanged. Consumers must treat it as "may have changed", which keeps early cutoff a pure optimization.
- **System bodies can't carry state from one row to the next.** Cross-row results (sums, counts) need an explicit reduction construct, so work can later be split without changing semantics.
- **Systems, rules and finalizers are identified by the values their constructors return,** never by name; across threads a kernel is its module and index. Closures can't cross threads, so configuration is passed as data.
-- **Public types are only those a consumer writes:** the interfaces to augment (`Components`, `Resources`, `Traits`, `Threads`), `Entity`, `Schema`, the math types, `Component.Key`, `Value` and `With`, `Resource.Key` and `Value`, `System`, `System.Commands`, `Finalizer`, `World`, and `Report` with `Report.Failure` and `Report.Warning`. `Archetype` and `Rule`, as `Component`, `Resource`, `Trait` and `Gpu`, name only namespaces. The rest are inferred from the values the API returns: a thread's name is `keyof Threads`, the cadence's value `Resource.Value`, a rule `ReturnType`, and an archetype any readonly list of components. Every type those values carry is named from the package root, so declarations a library emits for them name no internal module.
+- **Public types are only those a consumer writes:** the interfaces to augment (`Components`, `Resources`, `Traits`, `Threads`), `Entity`, `Schema`, the math types, `Component.Key`, `Value` and `With`, `Resource.Key` and `Value`, `System`, `System.Commands`, `Finalizer`, `World`, and `Report` with `Report.Failure` and `Report.Warning`. `Rule`, as `Component`, `Resource` and `Gpu`, names only a namespace. The rest are inferred from the values the API returns: a thread's name is `keyof Threads`, the cadence's value `Resource.Value`, a rule `ReturnType`, and an archetype is a list of components declared `as const`. Every type those values carry is named from the package root, so declarations a library emits for them name no internal module.
- **Components and resources:**
- - Each is identified by an exported key, a plain `Symbol(...)`, which TypeScript types as a `unique symbol`. Its traits are declared by hand, the source of truth: the module augments `Components` or `Resources` in `@adobe/ecs` with a record per key, `[parent]: { type: Entity; reference: "owner" }`, `type` the value's TypeScript type, and `Component.Value` is that type. Registering it, a side effect of loading its module, gives the same traits, `type` as a schema: `Component.register(parent, { type: Entity.schema, reference: "owner" })`. Its types check registration against the declaration both ways: the schema describes exactly the declared type (`Schema.ToType`, under which `Entity.schema` is `Entity`, a fixed-length array a tuple and `{ const: true }` `true`) and a default in it is of that type, every declared trait is given with its declared value, and nothing undeclared is given. A failed check names the problem where it can (`"the schema describes another type than the declared one"`). `type` is inferred from the schema alone and the checks are separate members of the parameter's type, so a registration's contextual typing never resolves them.
- - **Checks of a generic key read lookups:** while a registration's key is still being inferred, contextual typing resolves its checks for a generic key, and a recursive check of a generic value, such as `IsStruct`, recurses without end. Traits read whether a key's value is a struct from a lookup computed once for every key (`Structs` in `trait/types.ts`), its keys remapped (`as K`) so a generic key's lookup stays a lookup; the checks read `Components[K]["type"]` rather than `Component.Value`, whose deferred conditional resolves the same way.
- - **Traits** are strongly typed and externally extensible. A trait is defined by augmenting `Traits`, whose member gives the value the trait allows on component or resource `N` (`never` where it doesn't apply), and its key, a symbol too, is registered with `Trait.register`, in the registry components and resources share. The built-in traits (`type`, `derived`, `within`, `reference`, `reduce`, `thread`, `transfer`) are named by strings, which are reserved for the core. An allowed value may look at `N`'s own traits and at the one component a declared value names, never map over every component, which would make type checking quadratic in the component count. Types filter by trait: `Component.With`. Traits are a registry beside components rather than components of component entities: a trait's allowed value depends on the component it's on, which a component value can't express, and nothing reads traits on a hot path. Component entities can later project them read-only.
+ - Each is identified by an exported key, a plain `Symbol()`, which TypeScript types as a `unique symbol`. Its traits are declared once, the source of truth: the module augments `Components` or `Resources` in `@adobe/ecs` with a record per key, `[parent]: { type: Entity; reference: "owner" }`, `type` the value's TypeScript type, and `Component.Value` is that type. The plugin's pass before TypeScript is stripped (`compiler/augmentations.ts`) records each record right after its key, and after any binding the record names, a side effect of loading the key's module: the schema of `type`, its `default`, and its other traits as declared. Nothing restates a declaration, so nothing can disagree with it. A key is a top-level `Symbol()` constant of the module declaring it, so importing a key registers it and every declared key has an id; a module declaring a key it imports, or anything else, fails the build, naming both. The block itself does nothing at runtime, so code between a key and a block below it sees the key registered. The build also checks the built-in traits' values as far as their literals and types show (`derived` is `true`, `reference` an entity's `"owner"` or `"weak"`, `within` `typeof` a key, `transfer` a Transferable class, `reduce` an operator on a resource, `thread` a name on a value that isn't a struct or string), and `World.create` checks every declaration's traits against `Traits`, a default against its type among them: while one doesn't hold, each `World.create` call fails to compile, once, on the call: its `this` must then be `{ "declarations don't hold": { [key]: { [member]: problem } } }`, which tsc prints whole, naming each such key, its members by their bindings and what's wrong with each (`"isn't allowed on this key"`, `"isn't of the declared type"`, `"isn't a trait"`), and the world it returns type-checks as any other (`exports.test.ts`). That check is one mapped type over the declarations, each key's members checked against its own `Traits`. The package's own built-in keys register themselves, so the pass leaves its library modules alone. Emitted code imports nothing and consumers have no way to register a key themselves: a function appended to the module sets each key's `{ kind, declaration }` in a global `Map` under `Symbol.for("@adobe/ecs/declarations@1")`, which every copy of the package in the program reads, in the order modules recorded them, at the first read of a key after more were recorded, and none changes. A declaration that fails is recorded for its own key alone: a read of another key never throws for it, and every use of its key fails, each time, giving it as the reason, so a system using it fails in each run's report. A library's shipped build records its keys the same way. The check of declarations at `World.create` holds only where it's called as the package's `World.create`: assigned first to a variable typed `() => World`, it's no longer checked, a limit of checking through `this`; registration still checks what values show.
+ - **A type's schema** is read from its syntax, through aliases, interfaces, namespaces and re-exports of the module and those it imports, into the package's sources, which it ships, or a JavaScript package's declarations (`compiler/type-schemas.ts`); typed input reads its types the same way. `number`, `string`, `boolean`, `null`, literals, unions, arrays, one-type tuples, objects, `ReadonlySet` and `ReadonlyMap` have their schemas, and `Entity` its own. A type named by `Schema.Type`, which TypeScript resolves to `Schema.ToType`, a plain type, has the schema `S`, written as a literal type or `typeof` an `as const` constant: `type Level = Schema.Type<{ type: "integer"; minimum: 0; maximum: 9 }>`. The package's `F32`, `I32`, `U32` and vectors are declared so, so their precision and width come from their declarations, and a consumer's types the same. A class, a function, `unknown`, or a `Set` or `Map`, which changes in place, isn't data: the value gets no schema. Any other type fails the build at its declaration.
+ - **Traits** are strongly typed and externally extensible. A trait is defined by augmenting `Traits`, whose member gives the value the trait allows on component or resource `N` (`never` where it doesn't apply), keyed by a symbol too, which the build registers, in the registry components and resources share, before the components of its block. A trait's value is declared as a literal type or `typeof` a value, which the registration passes. The built-in traits (`type`, `derived`, `within`, `reference`, `reduce`, `thread`, `transfer`) are named by strings, which are reserved for the core. An allowed value may look at `N`'s own traits and at the one component a declared value names, never map over every component, which would make type checking quadratic in the component count. Types filter by trait: `Component.With`. The build checks the built-in traits' literals, registration what values tell, and `World.create` every trait's value against `Traits`, a trait of your own's included. Traits are a registry beside components rather than components of component entities: a trait's allowed value depends on the component it's on, which a component value can't express, and nothing reads traits on a hot path. Component entities can later project them read-only.
- **Entities are branded numbers** (`Entity`), and every entity-typed value, nested fields included, uses `Entity.schema`, so persistence and replication find references by schema. Index 0 is never an entity: `Entity.none`, zeroed memory and entity defaults name none, and a lookup of it returns null.
- - **A resource always has a value:** its schema's `default` or the declaration's, beside `type` (`Resource.register(names, { type: { type: "map", keys, values }, default: new Map() })`), either of the declared type and never both, left out only when the type allows `null`, which it then starts at.
- - **Values that aren't data** (functions, objects with methods, such as a `Blob`, an `HTMLElement` or a `GPUDevice`, a `Set` or `Map`, which changes in place, and objects holding any of them, looked at five levels deep) are declared by their type alone: registration has no `type`, the types require one exactly when the declared type is data, and a resource gives its `default` on the declaration unless its type allows `null`. The world stores such values as they are, so only main-thread systems use them, and persistence and replication skip them.
+ - **A resource always has a value,** and so does a derived component. A schema never holds a default, which isn't part of what values are: the start is the declaration's. The first of these applies:
+ 1. its `default`, declared beside `type` as a literal type (`default: -9.8`, `default: [1, 2, 3]`, `default: null`), never `typeof` a value;
+ 2. its type's namespace's `default`: the value beside the type alias with the same name (`type Height = F32; const Height = { default: -Infinity } as const`), read as `Height.default`, followed through imports, re-exports and aliases, and each field and tuple item of a type written in place takes its own type's start the same way. The package's types have one each: `F32.default`, `I32.default`, `U32.default` and the vectors' are zeros, `Quat.default` and `Mat4x4.default` the identities, `Entity.default` is `Entity.none`. A default is shared by every row and world that starts from it, so the build takes one only when it's evidently deeply readonly: `as const` as the outermost assertion on it or the whole namespace, with no cast inside to a mutable type (`[] as number[]` fails), or a `const` typed readonly (`ReadonlyMap`, `ReadonlySet`, `Readonly<…>`, readonly arrays and fields, an alias of one), whose annotation it trusts, or a call of a function whose declared return type is readonly (`packColor(255, 214, 0)` returning `U32`); a value it can't see as readonly fails the build at the part that isn't, saying why. One the build can read as data is checked against its type there. Imported as a type alone, a namespace's default the build can read as data is written into the registration, except an object a joined thread knows by identity, whose namespace is imported as a value;
+ 3. for a reduced resource, its operator's identity, leaf by leaf (`+` 0, `*` 1, `min` its largest value or +∞, `max` its smallest or −∞, `&` every bit of its column, all ones for a `u32` and −1 otherwise, or `true`, `|` and `^` 0 or `false`), as a fold of no contributions leaves it, taken only where it's one of the leaf's values, else the rules below; it outranks the package's own types' defaults, never a namespace of yours, at any depth;
+ 4. `null`, when its type allows it;
+ 5. its zero value: `0`, `false`, `""`, item by item for a tuple and field by field for a struct, and a union's literal that is one of those;
+ 6. a fresh empty `Map` or `Set` of each world's own, for a `ReadonlyMap` or `ReadonlySet`; a `Map` or `Set` type gets none.
+ Otherwise the build fails, saying how to give one: for a union of literals none of which is a zero value, a range without 0, a class, or a `Map` or `Set` that isn't null. A component's literal or namespace's default is its start the same way, which commands that give it no value add; a struct component without a literal needs none. The registration gives the start beside the schema, and checks it's one of the schema's values. A literal default is written into the registration, so it's a copy; a start that must be one object, as an object a joined thread fills is matched by identity, is a namespace's, read where it's declared, never copied.
+ - **Values that aren't data** (functions, objects with methods, such as a `Blob`, an `HTMLElement` or a `GPUDevice`, a `Set` or `Map`, which changes in place, and objects holding any of them) are declared by their type alone, and registered without a schema; a DOM `Node`, by its class or one extending it, needs `thread: "main"`, which the build requires. The world stores such values as they are, so only main-thread systems use them, and persistence and replication skip them.
- **Schemas** are JSON Schema, of the forms the ECS reads, and two JSON lacks: `{ type: "set", items }`, a `ReadonlySet`, and `{ type: "map", keys, values }`, a `ReadonlyMap`. Neither `"set"` nor `"map"` is a JSON Schema type, so every other schema keeps its meaning. A set or map is data, never a struct: it's stored as an object, on its thread, kernels can't read it, it crosses to other threads by structured clone, and registration compares defaults holding one by their entries.
- - **Import and use:** code that references a component or resource always imports its key. That guarantees the module is loaded, so the component is registered.
- - **Keys are plain symbols, compared by identity,** so no two separately declared components, resources or traits can collide, and a symbol needs no description. A key is one kind across components, resources and traits, because a resource may be stored as a component on a single entity. Registration rejects a string, a `Symbol.for` symbol, which any module could claim, and a well-known symbol such as `Symbol.iterator`; types reject strings too: `register` takes only symbol keys, and while `Components`, `Resources` or `Traits` has a string key that isn't a built-in trait, every component and resource declaration requires a property named for the problem (`"component keys must be symbols"`), so nothing registers until it's fixed. A key registered again as the same kind with the same declaration is accepted; as another kind, or with another declaration, it throws. A module evaluated again makes new symbols, which share its keys' ids (see the plugin).
+ - **Import and use:** code that references a component or resource always imports its key, and the key's module declares it and registers it right after the key, so importing a key registers it, on every thread. A module may not declare a key it imports: the build rejects it, naming both.
+ - **Keys are plain symbols, compared by identity,** so no two separately declared components, resources or traits can collide, and a symbol needs no description. A key is one kind across components, resources and traits, because a resource may be stored as a component on a single entity. Registration rejects a string, a `Symbol.for` symbol, which any module could claim, and a well-known symbol such as `Symbol.iterator`; the build rejects a declaration keyed by anything but a `Symbol()` constant. A key registered again as the same kind with the same declaration is accepted; as another kind, or with another declaration, it throws. A module evaluated again makes new symbols, which share its keys' ids (see the plugin).
- **Ids:** a symbol the plugin gave an id, registered as the kind and with the declaration of the key already registered under that id, is that key: the registry shares one entry between them, declarations compare them as one key, so systems, rules and finalizers declared again with them are accepted, and schedules, strata and finalizer order compare keys by the first symbol registered. A world stores them as one component, by the first symbol registered, resolving another on its first use, and gives values and changes keyed by the newest symbol registered. A symbol under a registered id with another kind or declaration throws at registration, as a module evaluated again after its declaration changed makes it. Keys aren't matched by description, and a symbol without an id is only itself.
- - **Labels:** errors and reports name a key by its label: its description while no other registered key has it, else its id, and without one its description numbered (`#2`) in registration order, so a label is short unless it would be ambiguous. Labels are for display; nothing looks a key up by its label.
-- **Archetypes** (`src/interface/archetype/`): an archetype is a readonly list of components, `readonly Component.Key[]`. `Archetype.create(components)` returns its argument, typed as the tuple given, throws for a component listed twice, and gives the list a program-wide index in a hidden property, by which worlds find what they resolved for it without comparing components. Any readonly list of components is an archetype too and goes wherever component lists do (systems, indexes, rules, finalizers); a plain list is compared by its components each time, since it may change, while an archetype must not change. A resource or string in one is a type error. The kernel compiler reads an array literal, or a constant holding one or `Archetype.create` of one, as its list: of the kernel's module, or imported, which it follows through the linker as it does inlined functions, re-exports and namespaces included, so editing the list's module compiles the kernel again. It reads an imported list's keys in the scope of the module that declares it, and names each in the kernel's module by the kernel's own binding of the same definition, or else through the list, as `List[k]`, so the kernel refers to the same symbols. A list it can't read, such as one computed when its module runs, fails the build at the call or declaration that names it. `add` and `remove` read their component the same way: one key when its definition is `Symbol(...)`, a list when it's one, and a build failure naming the call and the reason otherwise; a name from a package external to the build is taken as one key, since the build can't read it, and registration rejects a list there.
+ - **Labels:** errors and reports name a key by its label: its description, or without one its binding from its id, whatever other keys have, so a label never changes as modules load; an error about an id names the id too. Labels are for display; nothing looks a key up by its label.
+- **Archetypes:** an archetype is a list of components declared `as const`, a readonly tuple, which goes wherever component lists do (spawns, adds and removes, systems, indexes, rules, finalizers). There's no constructor: the build gives every list it can see an index. Before TypeScript is stripped it wraps each list declared `as const` that the module evaluates as it loads, outside functions, whose members are its `Symbol()` keys, imports, a namespace's members (`Report.run`) or spreads of such lists, as `archetype$([...] as const)` (`compiler/archetypes.ts`). After, it hoists each array literal of keys a main-thread body passes its commands into a module variable made once, `(list$0 ??= archetype$([a, b]))`, so the call allocates nothing (`compiler/spawns.ts`), only when no name declared around the body, nor its parameters, shadows a key it names, since the hoisted list is made once. The appended function, which imports nothing, gives a list a program-wide index in the property `Symbol.for("@adobe/ecs/archetype@1")`, counted by a global every copy of the package shares, when every member is a symbol, each once, and the list is extensible: it runs where the list is made, perhaps before its keys' declarations register, so whether they're components is checked where the list is used; worlds find what they resolved for it by that index without comparing components. A list naming one of its module's keys twice fails the build. Without `as const`, a list's keys widen to `symbol`, which no list of components takes, so the types require it. A list made at runtime is resolved by its keys among the eight recent ones; else its keys, in order, find the one index every list of them shares (`internArchetype`), given the first time, so lists made on every call take no index of their own and allocate nothing once seen. The list keeps that record of its keys and index in the same property, which holds while its keys stay the same, since such a list may change. The package's own lists, built without the plugin, take their indexes from `indexArchetype`. A resource or string in a list is a type error. The kernel compiler reads an array literal, or a constant holding one, indexed or not, as its list: of the kernel's module, or imported, which it follows through the linker as it does inlined functions, re-exports and namespaces included, so editing the list's module compiles the kernel again. It reads an imported list's keys in the scope of the module that declares it, and names each in the kernel's module by the kernel's own binding of the same definition, or else through the list, as `List[k]`, so the kernel refers to the same symbols. A list it can't read, such as one computed when its module runs, fails the build at the call or declaration that names it. `add` and `remove` read their component the same way: one key when its definition is `Symbol(...)`, a list when it's one, and a build failure naming the call and the reason otherwise; a name from a package external to the build is taken as one key, since the build can't read it, and registration rejects a list there.
- **Systems** (`src/interface/system/`; its type tests pin the behavior):
```ts
export const follow = System.create({
- name: "ai/follow",
- components: { read: [position, target], write: [velocity] },
+ components: { with: [position, target, velocity] },
lookup: { targets: { read: [position] } },
after: [integrate], // imported from the physics module
}, (row, { lookup }) => {
@@ -97,39 +104,39 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+.
});
export const move = System.create({
- name: "player/move",
- input: { type: "object", properties: { distance: { type: "number" } } },
components: { has: [player], write: [position] },
- }, (row, { input }) => { /* … */ });
+ }, (row, context, input: { readonly distance: number }) => { /* … */ });
- export const main = System.create({ name: "main", systems: [input, physics, follow, render] });
+ export const main = System.create({ systems: [input, physics, follow, render] });
```
- - **A system is the value its constructor returns.** `System.create` and `System.render` return a system typed `System`: its `name`, and the input `run` takes. Whatever else the value holds is the implementation's and isn't typed. `System.create`'s arguments tell its forms apart: one function is a body alone, two arguments a declaration and body, one array a composite's members and one object a composite's declaration, `{ systems, before, after, during, name }`. Its one-argument forms are a single overload, so a mistake in a body alone or a composite is reported at the mistake, not as no overload matching; the plugin compiles only the two-argument form as a kernel. They exist for body type inference, a binding the plugin can find, and a value the world can run. `Finalizer.create` and `Finalizer.changes` return a finalizer typed `Finalizer`, for other finalizers' `before` and `after`, and `Rule.create` a rule typed `Rule`, both their `name` alone. Nothing runs when they're called.
- - **Declaration keys mirror the body's context:** `components` and `resources` say what a system accesses, each with access modes (`read`, `write`) and filters; `lookup`, `reduce` and `input` each appear in the context under the same name.
- - **Components:** a system matches entities that have every component in `read`, `write`, `has`, `added` and `changed`, and none in `not`.
+ - **A system is the value its constructor returns.** `System.create` and `System.render` return a system typed `System`: its `name`, and the input `run` takes. Whatever else the value holds is the implementation's and isn't typed. `System.create`'s arguments tell its forms apart: one function is a body alone, two arguments a declaration and body, one array a composite's members and one object a composite's declaration, `{ systems, before, after, during }`. Its one-argument forms are a single overload, so a mistake in a body alone or a composite is reported at the mistake, not as no overload matching; the plugin compiles only the two-argument form as a kernel. They exist for body type inference, a binding the plugin can find, and a value the world can run. `Finalizer.create` and `Finalizer.changes` return a finalizer typed `Finalizer`, for other finalizers' `before` and `after`, and `Rule.create` a rule typed `Rule`, both their label, `name`, alone. Nothing runs when they're called.
+ - **A declaration says only what its body can't show:** which entities it runs over, its filters and conditions, its lookups' membership, its order. `lookup` appears in the context under the same name; every resource is in `resources`, writable but reduced and world-written ones, and every reduced resource in `reduce`.
+ - **Components:** a system matches entities that have every component in `with`, `added` and `changed`, and none in `not`.
- **Conditions:** `resources: { is, not }` say whether a system runs at all: only while each resource in `is` is neither `null` nor `false`, and each in `not` is. Otherwise it's skipped, which is a success, so systems ordered after it still run, and it keeps its `lastRun`. A condition's type must allow `null` or `false`, so `0` and `""` never turn a system off; `is` makes it readable, narrowed, so it isn't also read or written. Expected absence is a condition, not a failure, which is why any real failure is still reported. Finalizers have no conditions: they release what entities hold whatever the world's state.
- - A component in `write` is readable too, so it's never also in `read`; listing it in both is rejected in types and at registration. The same holds for resources.
- `added`/`changed` pass when **any** of the listed components was added or changed.
- - **Lookups** are named and read-only, and take an entity ID only: `(entity) => values | null`. They can't be iterated. A lookup's `changed` names components it reads or has; its row then has `changed`, whether one of them may have changed in the entity since the system last ran, as conservative as `changed` filters, true on a first run and in finalizers. It reads the row's ticks and its column's GPU stamp, and a move keeps them, so a move alone isn't a change. That row is one per lookup, valid until it's called again; a lookup without `changed` gives a new object per call, and one reading nothing a shared empty one. Each lookup's form is chosen once, at registration, so a lookup without `changed` pays nothing for it. The components it watches keep ticks and kernels' stamps while its system lives, as a filter's do.
+ - **Optional components:** `components: { with, optional, not, added, changed }`. An `optional` component never narrows what a system matches; its row holds the value where the entity has it and `undefined` where it doesn't, and assigning one an entity lacks fails the system, since adding it takes `commands.add`, as does assigning `undefined` to one it has, which removes nothing (`commands.remove` does). Which optional components an archetype has is fixed, so rows are made per pattern of them (`rowBinderOf`), each binding its present columns once per archetype and its absent ones as getters giving `undefined`: no check per row (`optional 100K` 0.42 ms against `with 100K` 0.42). Reads and writes of them are inferred and count for conflicts, change stamping and placement as members' do. A key in `added` or `changed` is a member, so naming one there makes it required. Lookups by entity take `optional` too, read where the entity has them and never required, and so do finalizers, whose stops still depend on `with` and `not` alone. CPU kernels read them through a presence column (`{ kind: "present" }`), any column with rows where the archetype has the component and empty where it doesn't, tested once per call: an absent component's columns are empty, so its reads give `undefined` as a body run as written does, and a write fails the system through `absent`, which names it (`labels`). A test of one for `undefined` lowers to that presence, and a kernel runs loops specialized for every optional component present and every one absent, and the general one only for archetypes with some: `optional 100K kernel` costs what `with 100K kernel` does. WASM runs archetypes with every optional component and JS the rest; a kernel whose rows run as a list (`up`, `order`) or whose lookup has optional components runs as JS. GPU kernels reject them, in types and at the build: WGSL has no `undefined` to read where an archetype lacks one, and a GPU kernel can't fail its system for one row. Tails and observer systems on joined threads read rows from snapshots across archetypes, which record each row's archetype, so they bind rows a run of one archetype at a time, every archetype with the same optional components sharing one copy of the columns; finalizers there bind their stops the same way.
+ - **Lookups** are named and read-only, and take an entity ID only: `(entity) => values | null`. They can't be iterated. A lookup by entity requires its `with` and excludes its `not`; its `changed` names components of its `with`, and its row then has `changed`, whether one of them may have changed in the entity since the system last ran, as conservative as `changed` filters, true on a first run and in finalizers. It reads the row's ticks and its column's GPU stamp, and a move keeps them, so a move alone isn't a change. That row is one per lookup, valid until it's called again; a lookup without `changed` gives a new object per call, and one reading nothing a shared empty one. Each lookup's form is chosen once, at registration, so a lookup without `changed` pays nothing for it. The components it watches keep ticks and kernels' stamps while its system lives, as a filter's do.
- **Resources:** listing a resource in `changed` makes the filter pass for every row. Kernels can only read resources.
- - **`kernel: "cpu"`** opts a system into being compiled, so it can run in chunks across workers; **`kernel: "gpu"`** narrows the subset so it can also run on the GPU. The term comes from CUDA, WebGPU and Taichi.
- - It's explicit rather than inferred, so a body that leaves the subset fails the build instead of silently running on the main thread.
- - Type-checked: values in `components` and `lookup` must be linear (numbers, booleans, entities, fixed-length tuples, and structs of those), and resources are read-only. Presence-only lists (`has`, `not`, `added`, `changed`) can name any component. A tuple or struct a command or spawn gives must go to a linear component, which registration checks.
- - The build validator checks `input`, since `Schema.ToType` doesn't produce tuples.
- - Without `kernel`, a system is plain JS on the main thread.
+ - **`kernel`** says whether a body compiles to a kernel, a function run on every matched row, in parallel, in chunks across workers. The term comes from CUDA, WebGPU and Taichi.
+ - Without it, the build tries a system with components, unless it observes or runs on one entity: it compiles when its body is in the subset, reads no module value but primitive constants, functions and the interface's own values (an object or array holding data, however it's made, may change after the build, which would inline it), and its schemas match, and otherwise runs as written. A system without components runs once, so it never compiles.
+ - `true` requires it, so a body that leaves the subset fails the build, saying why; `false` runs it as written, for debugging or a main-thread baseline; `"gpu"` narrows the subset so it can also run on the GPU.
+ - Checked by the build: what a kernel's body reads and writes of its rows and lookups must be linear (numbers, booleans, entities, fixed-length tuples, and structs of those), and it writes no resources. What it only requires (`with`, `not`, `added`, `changed`) can name any component.
+ - A kernel's input fields are structs. A TypeScript `number` is a float, so a field the kernel uses as an integer is typed `I32` or `U32`.
+ - **The pool starts once rows warrant it:** while no archetype a kernel matches holds half a piece of rows for a worker, kernels run on the world's thread and no workers start. A kernel registering looks at the archetypes it matches; one running here over that many rows starts the pool after its run's sync point, so nothing scans archetypes per run. Kernels failing to load on the pool a run starts are a `"thread"` failure in that run's report, labeled `workers`; the pool ends, and kernels run on the world's thread until the `workers` resource changes.
- **`components` is optional.** A system without it runs once per run, on the main thread, and its body takes only the context.
- - **`input`** is an object schema of named fields for a per-run, read-only value, and `run` requires it exactly when it's declared.
- - A system with input can't use `added` or `changed`, because its `lastRun` is shared across runs that receive different input.
- - **`target`** names an input field holding an entity: the system runs on that entity's row alone, once, when it's alive and matches `components`, and on nothing otherwise, so a stale entity from an event needs no check. Its writes are in place, stamped as any row's. It needs `components`, isn't a kernel, has no `up`, `order` or `reduce`, and runs only on the world's thread.
+ - **Input** is the body's parameter after its context, an object of named fields for a per-run, read-only value, typed as TypeScript: `run` requires it exactly when the body takes it. It's not part of the declaration, so its type is written once, where it's used.
+ - The plugin reads the schema the runtime needs from the parameter's type, by its syntax, before types are stripped (`compiler/input-schemas.ts`), and passes it to `create` after the body: type literals, aliases and interfaces of the module and those it imports, `number`, `string`, `boolean`, `null`, literals, unions, arrays, tuples of one type, `ReadonlySet`, `ReadonlyMap`, `(typeof X)[keyof typeof X]` for an `as const` object of literals, the package's `Entity` and math types, and `unknown`. A name neither declared nor imported is a global class, such as `OffscreenCanvas` or `HTMLCanvasElement`, which takes any value. Any other type fails the build at the parameter. A module whose input types come from a changed file is transformed again. Without the plugin, a body taking input throws at creation, as does one not written in place.
+ - Its checks are on the body, after the body is inferred: a system with input can't use `added` or `changed`, because its `lastRun` is shared across runs that receive different input; a `target` names an entity field of it; an observer system takes none. A check in the declaration's position can't see the input, which TypeScript infers only from the body, after the declaration.
+ - **`target`** names a field of the input holding an entity: the system runs on that entity's row alone, once, when it's alive and matches `components`, and on nothing otherwise, so a stale entity from an event needs no check. Its writes are in place, stamped as any row's. It needs `components`, isn't a kernel, has no `up`, `order` or `reduce`, and runs only on the world's thread.
- **A composite's input** has every field of its members' inputs, and each member receives the fields it declares, so workers get only those. A field several members declare is one value for all of them, with the same schema in each: a different type is a compile-time error at each member that differs, and creating the composite throws naming both. A field optional in one and required in another is required.
- `(system name, input)` is a replicable transaction. On a GPU, input maps to a uniform buffer.
- A `Blob` input can only be stored or forwarded, because its contents can't be read synchronously.
- - **Bodies are `(subject, context)`:** `(row, context)` for a system with `components` and for a finalizer, `(context)` for a system without `components`, `(changes, context)` for a changes finalizer, and `(gpu, context)` for a render system.
- - `row`: `{ readonly entity }`, plus `read` values (read-only) and `write` values (readable and writable). No component may be named `entity`.
- - `context` holds each kind under the declaration's own key, present only when the declaration declares something of that kind: `resources` (values, assignable when in `write`), `lookup` (functions by name), `reduce` (contribution functions by resource) and `input`. It also has `commands`, except in GPU kernels and render systems. Keys of different kinds never share a namespace, so nothing can collide.
- - `commands`: `spawn(components)` returns the world's spawner of exactly those components, the same function for a world and set of components each time, which takes their values and returns a real `Entity`; `despawn`; `add(entity, component, value)`, the value left out for a tag, or `add(entity, components, values)`, adding or replacing and marking the components changed; and `remove(entity, component)` or `remove(entity, components)`. A spawn's or list add's values are exact against its components: a tag needs no value, a component whose type allows `null` may be left out to start at `null`, every other one is required, and leaving one out or giving `undefined` never leaves the component out; a list that isn't a tuple takes any of its components' values. The two forms of `add` and `remove` are told apart by whether the second argument is a symbol. They apply in the order they were issued. They take only components that aren't derived, which is checked in types and at runtime, where a key that isn't a component commands may name, or a list naming one twice, throws at first use before anything is queued. Values are read only by the components' keys; a string key in the values of a spawn, or of an add whose components are a list as written, an array literal or a module constant of one or of `Archetype.create`, fails the build; an add of one component takes an object literal as its value.
+ - **Bodies are `(subject, context, input)`:** `(row, context, input)` for a system with `components`, `(context, input)` for a system without, `input` left out by a body that takes none; `(row, context)` for a finalizer, `(changes, context)` for a changes finalizer, and `(gpu, context)` for a render system.
+ - `row`: `{ readonly entity }`, plus the values of its `with`, `added` and `changed`, writable but references, and read-only in an observer or render system. No component may be named `entity`.
+ - `context` holds each kind under its own key: `resources` (every resource, assignable but reduced and world-written ones), `lookup` (functions by name, when declared), `reduce` (contribution functions by reduced resource) and `up` (with `up` declared). It also has `commands`, except in GPU kernels and render systems. Keys of different kinds never share a namespace, so nothing can collide.
+ - `commands`: `spawn(components)` returns the world's spawner of exactly those components, the same function for a world and set of components each time, which takes their values and returns a real `Entity`; `despawn`; `add(entity, component, value)`, the value left out for a tag, or `add(entity, components, values)`, adding or replacing and marking the components changed; and `remove(entity, component)` or `remove(entity, components)`. A spawn's or list add's values are exact against its components: a tag needs no value, a component whose type allows `null` may be left out to start at `null`, every other one is required, and leaving one out or giving `undefined` never leaves the component out; a list that isn't a tuple takes any of its components' values. The two forms of `add` and `remove` are told apart by whether the second argument is a symbol. They apply in the order they were issued. They take only components that aren't derived, which is checked in types and at runtime, where a key that isn't a component commands may name, or a list naming one twice, throws at first use before anything is queued. Values are read only by the components' keys; a string key in the values of a spawn, or of an add whose components are a list as written, an array literal or a module constant of one, fails the build; an add of one component takes an object literal as its value.
- **Writes to the current row are direct and immediate.** Commands are only for structural changes and for writes to other entities.
- - **Names are labels,** optional and not unique, so two libraries can both name a system `"linked"`; namespacing them (`"physics/integrate"`) keeps labels short. A system that declares nothing is created from its body alone, `System.create(({ commands }) => …)`, which runs once per run. Ordering constraints list systems; a system can't list itself, since it doesn't exist while it's declared.
+ - **Declarations take no name.** A system is labeled by its binding, which its id names, so two libraries can both declare a system `linked`. A system that declares nothing is created from its body alone, `System.create(({ commands }) => …)`, which runs once per run. Ordering constraints list systems; a system can't list itself, since it doesn't exist while it's declared.
- **Composites** flatten their members.
- A composite's constraints apply to every member, and members' own constraints resolve against the flattened set.
- If the same system appears twice, as one value or as values of one id (see below), it's flattened to one entry, the first reached. Systems that only share a name are different systems.
@@ -142,15 +149,15 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+.
- **`during` places a system alongside another:** after what that system follows and before what follows it, both by its own constraints and by others', outside a composite's own members. No-op systems ordered one after another are phases, and systems during different phases are ordered with each other. Where no ordering path separates them, the two share a tier.
- **Unordered conflicts throw when a composite is created:** within a composite, two systems that both write a component or resource, or where one reads what the other writes, must have an ordering path between them. Contributors to one reduced resource don't conflict with each other, and every system that reads it is ordered after all of them.
- **A lookup can't read a component its own system writes;** that throws at registration. `up` can, since it reads a parent only after the parent ran.
- - **`up: { through, read }` runs rows in depth order** through `through`, a reference component, parent first, and the context's `up` holds what `read` lists of the row's parent, or null without one. A parent's value is the one written earlier in the same run, so one run carries a change down a whole tree, and results don't depend on chunking. It's Flecs's depth-ordered (`cascade`) iteration, with the parent's values in the context.
+ - **`up: { through }` runs rows in depth order** through `through`, a reference component, parent first, and the context's `up` holds what the body reads of the row's parent, or null without one or for a parent lacking one of them. A parent's value is the one written earlier in the same run, so one run carries a change down a whole tree, and results don't depend on chunking. It's Flecs's depth-ordered (`cascade`) iteration, with the parent's values in the context.
- `through` doesn't narrow the match: rows without it are roots. References change only through commands, so the tree changes only at sync points.
- A row's depth counts its ancestors among the matched rows: a row whose parent doesn't match is a root of this system's order.
- With change filters, a row also passes when its parent reference, or what it reads of its parent, changed since the system last ran, decided on ticks, so a parent another system wrote counts too. Those components keep ticks and kernels keep their stamps. A row that moved to another archetype since then passes too, since removing its parent reference moves it, as do the children of a parent that moved to another archetype or started or stopped matching.
- Rows on or under a parent cycle don't run; reports don't list them yet.
- **Order:** rows run depth by depth, and within a depth in groups of one archetype whose parents share one archetype, each in storage order. Groups of a depth never read each other's writes, so values don't depend on the grouping; commands and reductions follow it, deterministically. The order, each row's parent row and the children of each parent change only at sync points, since only those move rows or change references.
- **Kernels read `up` straight from the parent's columns:** a call covers one group, so it binds the parent archetype's columns once, and each row reads its parent's leaf at the parent's row. A leaf's stride and offset depend only on its component's layout, so they're the same in every archetype. `up === null` holds for the whole call.
- - **Filtered runs push changes down:** rows are marked from chunk ticks of their own filters and their parent reference, from the sync points' journal of entities whose parent, matching or archetype changed, and as children of parents whose read values changed; each row that runs marks its children, so a run costs a check of each chunk plus the subtrees that changed, and nothing when nothing did.
- - **`kernel: "gpu"` systems may declare `up`;** what `read` lists must be made of 4-byte scalars, as the rest of a GPU kernel's values are.
+ - **Filtered runs push changes down:** rows are marked from chunk and block ticks of their own filters and their parent reference, from the sync points' journal of entities whose parent, matching or archetype changed, and as children of parents whose read values changed; each row that runs marks its children, so a run costs a check of each chunk plus the subtrees that changed, and nothing when nothing did.
+ - **`kernel: "gpu"` systems may declare `up`;** what the body reads of the parent must be made of 4-byte scalars, as the rest of a GPU kernel's values are.
- **In a main-thread body, `context.up` is one reused object,** valid only while its row runs, as `row` is.
- **World** (`src/interface/world/`):
- `World.create(resources?)` returns a world synchronously, on the creating thread, with resources starting at the given values in place of their defaults; a key that isn't a resource, or one only the world writes, throws; string keys are ignored, as in commands. Built-in resources are symbols too, so initial values import them: `World.create({ [workers]: 0 })`.
@@ -163,16 +170,16 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+.
- **Threads:** `world.share(port)` lets another thread join through a port (a `MessagePort`, a `Worker` or a worker's scope), returning nothing; the joined thread's progress and claims are the implementation's own storage. The other thread calls the implementation's `join(port, { threads })`, whose port and options are inferred from `share`, claiming the logical threads it fills, and gets a `World` whose `run` works as here, and whose `add` and `remove` take observer systems, which run there after the world's sync points, by the ids the plugin gives top-level systems and keys (an observer system it adds needs none, only its keys do): a run of a system without one, or one the world never imported, rejects, and a thread claimed twice, or one with a system or finalizer it can't run, without an id, naming a key without one, declaring `up` or `order`, looking up through an index with an object key part or with `changed: true`, or not imported there, can't join ([`threads.md`](threads.md)). The objects and object resources of the threads it claims are its own: commands elsewhere give them only their start or `null`, and systems elsewhere can't read them; the systems that read or write them run there, each tier waiting for it listed in the report's `warnings`, and are skipped with a `"thread"` failure once it's gone, later tiers still running. A terminal one, whose body the plugin proves never reaches `commands` and which writes only those objects, reduces into nothing and looks nothing up, runs as a tail when no later system conflicts with it: after its tick's sync point, unwarned, while the next tick goes on, a writer of what it still reads first copying the rest of its reads and waiting for at most the chunk it claimed; a run waits for its tails, and a tick's report is written once after them, with their failures. The finalizers that read them run there too, after the sync point: the world copies each stop's declared struct reads into that thread's stop list without waiting, the thread runs them as it next replays that sync, before its next task or observer post, and the index of an entity they stopped is held until the thread acknowledges; their failures are in a later run's report. When it's gone its objects come back to the world's thread, its held indexes return, stops it never finalized are `"thread"` failures, and its systems and finalizers run here again. Once the world is disposed, a joined thread finalizes every row its finalizers match. Without cross-origin isolation both throw.
- **Observer systems are the way to read a world.** A consumer reads once by running an observer system that copies what it reads into variables of its own, and follows changes by adding one whose `changed` filters pass only after the runs and ticks that changed what it reads; the world has no observers. Derived values are computed by systems, inside the ECS.
- **Observer factories** (`src/observe/`, internal, never exported: consumers write plain observer systems; its own TypeScript project importing only the package root, for tests, debugging and benchmarks) give observers as `Observe.resource(world, key)`, `Observe.entity(world, entity, { required, optional })` (no whole-entity form), `Observe.select(world, query)`, `Observe.count(world, query)` and `Observe.toPromise`, built on observer systems alone. Per world, in a `WeakMap`: one observer system per resource key and per shape of entity observer (its required and optional lists) or query (components, `where` keys, `order`, whether paged), shared by every subscriber, each `where` and page of a query an entry. A value they hold goes to a new subscriber during the subscribing call, as of the last sync point; any other waits for the flush at the end of the task, which runs what reads it (a resource's reader, a query's scan) and one delivering run, after every run called before, so cold first values are read-your-writes and come in subscribe order; a first value whose notify throws is thrown out of band and stops that subscriber, a later one's is a `"system"` failure of its run or tick. A maintenance observer system, added first, counts passes and removes shapes the passes found idle in three, so loops of first values stay warm; a resource's reader runs before it in its own run, and its notifier, filtering on the resource's `changed`, skips subscribers that had their first value in that pass. The failures of the factories' own runs, which nothing else reads, are rethrown out of band: each of their runs runs a system reading reports, `Observe/rethrow`, never added so no tick waits for it, which rethrows their systems' failures in the reports of their runs, a pass's failures once a later run of theirs sees them; on a joined thread, they're in a later report of the world's. Entity shapes look each entity up by entity with `changed`, reading values only once changed, and with no optional components skip every entity while an index without a key on the required ones reports no change. A query's first values come from one scan, an observer row system run for them, ties and unordered results put in entity order; a scan of one entry with at most one test, every entity or a count, gathers in the scanner's body into typed scratch, each value made at its length; only once a subscriber outlives its first value is the shape's tracker added: a sorted index on `order` when every term goes one direction, read from its end for descending, each run of equal keys in entity order; for a count, or a selection without a page, a sorted index on its first `where` key whose values have an order, each entry looking up its range with every bound made inclusive, so it holds every row the condition passes at the precision values are kept at, then testing each row, or for a count of one condition, taking the range's length less the rows at a bound the test rejects (rows at one key share their value, so one tells for all); otherwise an index without a key, in entity order. Each notifies only when its result differs; unordered results in entity order compare as sequences. Disposed worlds run nothing, so a first value not held never comes. `observe-cases.test.ts`, `observe-ticks-cases.test.ts`, `observe-cases.browser.test.ts` and `observe-factories.test.ts` cover them, and tests read through them (`reads.test-helper.ts`); tests of storage order, or of a joined thread's objects as of the world's last sync point, read through observer runs. Benchmarks count ticks with an observer system filtering on the clock, and read before what they time through observer runs, which leave nothing added (`perftest/observer.ts`); the factories only check results once nothing more is timed on a world, since they keep their maintenance system added there. On a joined thread's world, which the factories know by its `remove` refusing a system that isn't an observer, they add no maintenance or delivering system, since observer systems run there in posts in no order with one another: a shape's cold first values come once a run of its own system resolved, a post for it having run after every run the thread called before subscribing (a resource's notifier, whose first run once added passes its filter; an entity shape's system; a query's tracker, added on first use, with no scan, since a scan's rows can't be told from a later post's there, and for an ordered selection without a page an index without a key sorted there rather than a sorted one the world's thread keeps); a pass is a turn of the thread's event loop that ran a shape's system, idle shapes swept after it; indexes they make reach the world by declaration; a system the thread can't place, as for objects of the world's thread, throws when subscribed. On the world's thread, one naming objects a joined thread fills fails to register: an error in a run's report, or out of band for the factories' own runs, whose rejections on a live world are rethrown. `observe-joined-cases.test.ts` covers them on a joined thread, and the worker cases of `remote-objects` and `joining` on joined workers.
- - **Identity is the value, and names are labels:** no name is ever looked up, so systems, rules and finalizers can't collide across separately written code, as keys can't. Errors and reports name one by its label: its name while no other registered system, rule or finalizer has it, else its name with its id (`"linked (lib/src/link.ts#linked)"`), and without one its name numbered (`#2`) in registration order. One declared without a name is labeled by its binding and id (`"spawnOnce (app/src/setup.ts#spawnOnce)"`), and without an id by its kind numbered by creation (`"system #3"`, `"rule #1"`, `"finalizer #2"`). Labels are for display.
+ - **Identity is the value, and labels are bindings:** no label is ever looked up, so systems, rules and finalizers can't collide across separately written code, as keys can't. Errors and reports name one by its label: the binding its id names (`linked` for `lib@1.0.0/src/link.ts#linked`); for one made where no top-level binding is, the binding, function, property or method the plugin found it in (`mover` for one made in `const mover = (speed) => System.create(...)`), with no id; and else its kind numbered by creation (`"system #3"`, `"rule #1"`, `"finalizer #2"`). A label never changes as other items load, and reports give the id beside it to tell items of one label apart. Internal factories label theirs through the registry's map of labels, which the plugin's labels never override.
- **Ids:** an item the plugin gave an id, declared again under that id with the same kind and the same declaration, its keys and the systems and finalizers it lists matching by id, is that item, as a module evaluated again declares it: a system is a new value, with the new body, that schedules compare as the first one, so constraints from modules evaluated again still order it; a rule is the one already recorded and `Rule.create` returns it; a finalizer keeps its place in every world with the new body and `Finalizer.create` returns it. The same id with another kind or declaration throws, telling to reload the page: replacing a rule or finalizer would have to undo what worlds derived or finalized under the old one. An item without an id is only itself: declared again, it's a new item. Constraints list the systems themselves, imported from their modules; when two modules import each other, one declares the order.
- `run(system, ...input): Promise` runs a system or composite, ends in a sync point, and resolves to its run number. Runs are sequential in call order; physically, runs that don't conflict may overlap.
- It's async because workers can't be joined synchronously on the main thread.
- **Registration is the first run:** a system's declaration is checked when it's created, with the components and resources it names, which must be registered by then: every check that needs no world, its reductions, conditions, `up`, placement and render accessors included. A compiled kernel is checked against its declaration and schemas where its module registers it, and a `within` cycle when the component closing it registers. The first time a world runs a system, it gives its keys ids, adds its resources and loads its kernels, serialized with runs, and the run rejects without recording anything if that fails: a kernel the build didn't compile, a kernel module or WASM that fails to load, and a GPU kernel whose shader can't be generated from its bound columns are found there. A kernel's first run pays for its load; a run before timing work avoids it.
- **Observer systems** run after its sync point, before it resolves (see the observer pass).
- - **What went wrong is its report** (see Reports are entities): `failures` (each a `kind` of `"system"`, `"finalizer"`, `"gpu"`, `"thread"` or `"index"`, the `name`, a system's, finalizer's or index's label, the `message`, and the engine's `stack` or null), the labels of the `skipped` systems of later tiers and of joined threads that are gone, the count of `dropped` commands against entities that weren't alive, and `warnings` of what slowed it, a `"blocking"` tier that waited for a joined thread by its system's label. A throw's value is normalized to text, so a report is plain data; domain errors are data a system writes, not throws. It rejects only when the run can't happen or finish: registration fails, the world is disposed, its GPU device is lost, or its sync point can't apply a command's value; a rejected run takes no number.
+ - **What went wrong is its report** (see Reports are entities): `failures` (each a `kind` of `"system"`, `"finalizer"`, `"gpu"`, `"thread"` or `"index"`, the `name`, a system's, finalizer's or index's label, its `id`, the plugin's or null, the `message`, and the engine's `stack` or null), the labels of the `skipped` systems of later tiers and of joined threads that are gone, the count of `dropped` commands against entities that weren't alive, and `warnings` of what slowed it, a `"blocking"` tier that waited for a joined thread by its system's label and id. A throw's value is normalized to text, so a report is plain data; domain errors are data a system writes, not throws. It rejects only when the run can't happen or finish: registration fails, the world is disposed, its GPU device is lost, or its sync point can't apply a command's value; a rejected run takes no number.
- Each system keeps its own `lastRun`, so it sees every change since it last ran.
- `dispose()` and `[Symbol.asyncDispose]` wait for pending runs, then stop ticking, terminate workers and release memory, so `await using world = ...` works.
- - **Initial state** comes from the resources' schema defaults. A system with `resources: { changed: [level] }` runs on its first run and spawns the entities the level calls for, so no run-once flag is needed.
+ - **Initial state** comes from the resources' starts. A system with `resources: { changed: [level] }` runs on its first run and spawns the entities the level calls for, so no run-once flag is needed.
- **A sync point runs in three phases:**
1. **Plan.** Merge command buffers in the run's sorted system order, then chunk index, never worker ID, so conflicting commands resolve the same way every run. Expand the declared cascades and the rule closure. The result is a net plan of `fromArch → toArch` for each entity, covering the whole flush, with nothing applied yet. Only the net first→last archetype matters, so an entity that passes A→B→C produces no events for B.
2. **Finalize, once,** against the final plan, while every row is still live (see Finalizers).
@@ -187,7 +194,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+.
- **Matching, not component removal, is the unit of add/remove symmetry.** An entity stops matching a query when a required component is removed, when it's despawned, or when an excluded component is added. Per-component removal is a monitored `With`.
- Monitored queries are **structural only**. Their `Changed`/`Added` filters are stripped or rejected at the type level, and optional components don't affect matching. Value conditions become tag components set by systems.
- Replacing a required component within one flush (remove + re-add) counts as a stop plus a start.
-- **Rules (structural derivation):** `Rule.create({ name, components: { has, not }, derive: [component] })`: entities matching its components have the derived components.
+- **Rules (structural derivation):** `Rule.create({ components: { has, not }, derive: [component] })`: entities matching its components have the derived components.
- **Datalog with stratified negation** over which components each entity has. Rules and `within` declarations are evaluated as one fixed point per stratum; `Rule.create` rejects a cycle through a negation, which could oscillate, and any cycle through `within`. That makes the closure a pure function of the archetype, cached per archetype, so A→B becomes a single move A→closure(B), and entities never match and then unmatch within a flush. Reference cascades run first, on the values the plan leaves; they only despawn and remove, so the result is the same fixed point.
- **Union ownership:** a derived component is present while any rule deriving it matches.
- **Derived components** (`derived: true`) are structurally read-only to user code: commands reject them in types and at runtime; systems write their values. Each starts at its schema's `default`, which the types require, stamped `added`, and a normal `added` system fills it in. No initializer phase is needed, because creation can wait for the next system run and removal can't.
@@ -195,8 +202,8 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+.
- **Cascades** are component traits, expanded in Plan with the rules.
- **`within: host`:** the component exists only while its host does, whatever removes the host; transitively; in command order, so removing a host and adding it back ends its dependents. A derived host a rule keeps never leaves. A dependent added without its host is dropped.
- **`reference: "owner" | "weak"`** on an entity-valued component: when the target despawns, or a reference is written to an entity that won't be alive, `owner` despawns the holder and `weak` removes the component, transitively. Only the value the plan leaves counts, so a reference pointed elsewhere in the same sync point stays. References change only through commands (systems can't list them in `components.write`), which keeps the index of holders by target exact. Without `reference`, a reference dangles and a lookup of it returns null.
-- **Finalizers are the only removal mechanism.** `Finalizer.create({ name, components: { read, has, not }, lookup, resources, before, after }, (row, { resources, lookup, commands }) => …)`. They run inside the sync point, on the world's thread, against the final plan and live values from before Apply, with no copying. They are for releasing resources (GPU/DOM handles), updating indexes and adjusting aggregates, and they never miss a removal.
- - **On another thread:** one whose objects live on another thread than the world's, which a joined thread may fill, runs there after the sync point, with its declared struct reads copied and its thread's objects as of before Apply ([`threads.md`](threads.md)). So it issues no commands, writes no resources and looks up nothing, in types and at creation; a changes finalizer watches and reads only the world's thread's values; and finalizers on different threads can't be ordered with `before` or `after`.
+- **Finalizers are the only removal mechanism.** `Finalizer.create({ components: { with, not }, lookup, before, after }, (row, { resources, lookup, commands }) => …)`, what its body reads and writes inferred as a system's is. They run inside the sync point, on the world's thread, against the final plan and live values from before Apply, with no copying. They are for releasing resources (GPU/DOM handles), updating indexes and adjusting aggregates, and they never miss a removal.
+ - **On another thread:** one whose objects live on another thread than the world's, which a joined thread may fill, runs there after the sync point, with its declared struct and string reads and its resources copied and its thread's objects as of before Apply ([`threads.md`](threads.md)). So it issues no commands, writes no resources and looks up nothing, in types and at creation; a changes finalizer watches and reads only the world's thread's values; and finalizers on different threads can't be ordered with `before` or `after`.
- **A stop:** the entity matched before the sync point and is despawned, loses a required component by any cause (a command, a rule, `within` or a reference), has one removed and added again, or gains an excluded one. An entity spawned and despawned in the same sync point never matched.
- **Deferred handling:** a consumer that handles removals later appends what it needs (IDs, values) to a queue it owns during Finalize, and works through it whenever it runs. A consumer that tracks stops keeps state about the entities it saw start, and ignores stops for entities it doesn't know.
- **Run once, against the final plan.** Their structural commands go to the **next** sync point. Re-planning would let a later round bring back a component an earlier round already finalized, for example a freed handle on an entity that survives.
@@ -207,11 +214,11 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+.
- **Order:** `before` and `after` list other finalizers, then creation order. Two that both write a resource, or where one reads what the other writes, must be ordered. Every active finalizer runs at every sync point; none is in a composite, and the phase costs nothing when none is active.
- **Throwing:** the plan still applies, and the run's report lists each failure.
- **`dispose()`** runs per-query finalizers for every live match, so handles are released, and no whole-plan finalizers.
-- **Whole-plan finalizers** see the sync point's structural changes rather than one query's stops, for persistence, replication and structural undo: `Finalizer.changes({ name, watch }, (changes, context) => …)`. Changes are grouped by archetype pair (`from`, `to`, null for spawned or despawned, `added`, `removed`, `replaced`, `entities`), `replaced` listing the components an add gave a value while the entity had them before and after, so an edit committed as one add is one change, filtered to `watch`, by default every component that isn't derived or an object a joined thread fills; a derived one is rejected. `previous(entity, component)` and `next(entity, component)` give values before and after. They are ordered with per-query finalizers.
+- **Whole-plan finalizers** see the sync point's structural changes rather than one query's stops, for persistence, replication and structural undo: `Finalizer.changes({ watch }, (changes, context) => …)`. Changes are grouped by archetype pair (`from`, `to`, null for spawned or despawned, `added`, `removed`, `replaced`, `entities`), `replaced` listing the components an add gave a value while the entity had them before and after, so an edit committed as one add is one change, filtered to `watch`, by default every component that isn't derived or an object a joined thread fills; a derived one is rejected. `previous(entity, component)` and `next(entity, component)` give values before and after. They are ordered with per-query finalizers.
- **Async resources** need no other API: a rule derives a slot; a system with `added: [slot]` writes a fresh token into it and starts the load; the completion calls `world.run(apply, { entity, token, value })`, which adds the ready component (`within: slot`) if the slot's token still matches and releases the value otherwise; a finalizer on the slot aborts the load, and one on the ready component releases it. Shared assets are reference-counted by the loader, since the ECS gives each user exactly one acquire and one release.
- **Reductions** compute one value from many rows, in kernels across workers as well as on the main thread.
- - **The operator is a resource trait,** `Resource.register(total, { type: { type: "number", default: 0 }, reduce: "+" })`: `"+" "*" "min" "max" "&" "|" "^"`, applied leaf by leaf to numbers, tuples and structs, and on booleans only bitwise. A contributing system repeats it, `reduce: { [total]: "+" }`; the types require the two to match and registration checks it again, so changing a resource's operator breaks every contributor.
+ - **The operator is a resource trait,** `[total]: { type: F32; default: 0; reduce: "+" }`: `"+" "*" "min" "max" "&" "|" "^"`, applied leaf by leaf to numbers, tuples and structs, and on booleans only bitwise. A contributing system lists the resource, `resources: { reduce: [total] }`, and folds with its trait's operator, which a kernel reads at build time from the module declaring the resource: its augmentation in TypeScript, or in JavaScript, as a library's `dist` ships it, the registration its own build gave it; changing a resource's operator changes every contributor.
- **The body contributes** with `reduce[total](value)`; it can't read the fold, and a contributor can't read or filter on what it reduces into. A reduced resource is never written, by systems or finalizers.
- **Each run starts again:** a run that includes a contributor sets the resource to the operator's identity, and it ends up holding the fold of every contribution in that run, stamped changed only if it differs. Several systems may contribute in one run; they don't conflict with each other, and every system that reads the resource is ordered after all of them. A run without a contributor leaves it as it is, and before the first, it holds its `default`.
- **A failed or skipped run:** when a contributor fails, or no contributor runs its rows, the resource keeps its previous value.
@@ -220,8 +227,8 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+.
- **Contributions have a known shape:** a tuple or struct component contributed whole needs its leaves known, as whole-value writes do.
- **Indexes** (`src/interface/indexes/`; its type tests pin the behavior) find rows by the values of stored components, for systems and finalizers.
- - **Declared once, program-wide:** `Index.create({ name, key: [cell], components: { has, not }, sorted, unique })` returns an `Index`, typed by its declaration as written, so a library's emitted declarations name it from the package root and `Index<{ readonly sorted: true }>` accepts any sorted index. The plugin gives top-level bindings ids, as it gives systems. It holds the entities with every key part and `has` component and no `not` one. With an empty key, `key: []`, it holds them by membership alone, every entity but the resource entity when `has` and `not` are empty too, and isn't unique. Key parts are components of data; a sorted index orders numbers numerically, NaN last and -0 as +0, booleans false first, strings by code point, never locale, and tuples element by element, and ties by entity index. Object parts must all live on one thread, the world's beside a string part, since the world keeps keys of strings. A computed key is a component a system computes and writes, as any value.
- - **Lookups:** `lookup: { near: { index: grid, read: [position], changed: [position] } }` gives the context a function taking the key's parts as arguments. A hash index takes the whole key and gives its bucket's rows in entity order; a sorted index takes a prefix of the key, possibly empty, whose last part may be a comparison (`{ ">=": t0, "<": t1 }`, `==`, `!=`, `<`, `<=`, `>`, `>=`, together narrowing it), and gives its rows in key order, then entity order; an index without a key takes none and gives every row it holds, in entity order; a unique index given a whole key gives the row or null. A list has `length`, `changed` and `at(i)`; a row has `entity` and what `read` names, which every entity the index holds must have. `changed` is whether an entity entered, left or moved in the list, or a component in `changed` changed in a row of it, since the system last ran; only those components keep ticks for it. The plugin gives each call site in a main-thread body its own list, reused, so a list is valid until its site runs again and a row until its list is reused; a call it didn't rewrite makes a new list.
+ - **Declared once, program-wide:** `Index.create({ key: [cell], components: { with, not }, sorted, unique })` returns an `Index`, typed by its declaration as written, so a library's emitted declarations name it from the package root and `Index<{ readonly sorted: true }>` accepts any sorted index. The plugin gives top-level bindings ids, as it gives systems. It holds the entities with every key part and `has` component and no `not` one. With an empty key, `key: []`, it holds them by membership alone, every entity but the resource entity when `has` and `not` are empty too, and isn't unique. Key parts are components of data; a sorted index orders numbers numerically, NaN last and -0 as +0, booleans false first, strings by code point, never locale, and tuples element by element, and ties by entity index. Object parts must all live on one thread, the world's beside a string part, since the world keeps keys of strings. A computed key is a component a system computes and writes, as any value.
+ - **Lookups:** `lookup: { near: { index: grid, changed: [position] } }` gives the context a function taking the key's parts as arguments. A hash index takes the whole key and gives its bucket's rows in entity order; a sorted index takes a prefix of the key, possibly empty, whose last part may be a comparison (`{ ">=": t0, "<": t1 }`, `==`, `!=`, `<`, `<=`, `>`, `>=`, together narrowing it), and gives its rows in key order, then entity order; an index without a key takes none and gives every row it holds, in entity order; a unique index given a whole key gives the row or null. A list has `length`, `changed` and `at(i)`; a row has `entity` and what `read` names, which every entity the index holds must have. `changed` is whether an entity entered, left or moved in the list, or a component in `changed` changed in a row of it, since the system last ran; only those components keep ticks for it. The plugin gives each call site in a main-thread body its own list, reused, so a list is valid until its site runs again and a row until its list is reused; a call it didn't rewrite makes a new list.
- **`changed: true`** on a system's index lookup makes the index a change filter, as a resource in `changed` is: every row passes when, since the system last ran, an entity entered or left the index or moved to another key or position in it, as its refreshes count them; a rebuild counts as a change, and a key written the same, a move to another archetype, or a change to what the index doesn't key on doesn't. With several, any one changing passes. A system whose only change filters these are, with no `up`, is skipped before its body when none changed, as when a condition fails, keeping its `lastRun`, at one comparison per such lookup, against a tick each index keeps of its last refresh that changed an entry; with other change filters its rows pass by them otherwise. Conditions still decide first. Its lists' `changed` then reports entries alone. Observer systems and CPU kernels take it, the world deciding before it calls them; finalizers, which run per entity, don't, and a system on a joined thread, or an observer placed on or added on one, is rejected, the decision needing the world's index. Systems without it pay one boolean test per run.
- **`order: index`** runs a system's rows that a sorted index holds in its order, with change filters the rows that pass; commands and reductions follow it. Not with `up`, and not on GPU kernels.
- **Scheduling and currency:** a system using an index, through a lookup or `order`, reads its key parts, for ordering, conflicts and placement, and can't write them or what it reads through the index. It sees key values written by systems ordered before it in the run, and entities as of the last sync point; a finalizer sees the index as of before its sync point applies, as its lookups see rows.
@@ -234,7 +241,7 @@ Rule: anything that doesn't affect the API or correctness belongs in phase 2+.
1. **Kernel subset.** Kernels compile to efficient JS or WASM. Main-thread systems run as normal JS with every feature available.
- **Parser:** the bundler's own (`this.parse`, ESTree, in both Vite and Rolldown); the compiler validates the subset on that tree, and an ESLint rule can share the validator since ESLint also produces ESTree. A second JS parser would only add size and upkeep.
- **Closed world:** every input comes from the ECS (query rows, resources).
- - **Precision:** arithmetic has at least the storage precision. Results are not bit-exact across backends (JS, WASM, GPU).
+ - **Precision:** a CPU kernel compiled without `kernel: true` computes as its body as written does, in f64, rounding only where it stores, in JS and WASM alike, so compiling it never changes a result. A kernel the declaration asks for may compute at storage precision, as WASM's f32 SIMD lanes do, rounding each step: about 4.4 times faster for `move` and `chain` than one row at a time in f64, and not bit-exact with the body as written. GPU kernels compute in f32.
- **Imported functions:** kernels call pure functions and use constants from any module the build resolves to a file, the kernel's own module included: user TypeScript or JavaScript, other packages' ESM, and the package's math types. The plugin reads each module a kernel reaches, with types stripped, follows its imports and re-exports, and inlines each call compiled in its own module's scope, so tuples are scalarized and never allocate, on JS, WASM and the GPU alike. A function outside the subset fails the build at the kernel's call, with a frame naming the file and line of each inlined function down to the failure. Recursion fails, also across modules. A module's constants must be known at build time: a `let`, or a mutation at runtime, isn't seen. Editing an inlined file compiles its kernels again, in dev too.
- **Math types:** the package exports math types that are also namespaces of functions: `Vec2`, `Vec3`, `Vec4` (each `F32`, with `I32` and `U32` nested beside it), `Quat`, `Mat4x4`, and the scalars `F32`, `I32`, `U32`, each with a `schema`. They follow WGSL's semantics, and where WGSL leaves a result indeterminate they do what is fastest: integer vectors wrap, `round` rounds halfway values to even, `mod` is `x - m * floor(x / m)`, and `normalize` of a zero vector is NaN. Integer functions are defined for integers in range, and `pow` for a positive base; other inputs give indeterminate results, which may differ between the CPU and the GPU. The few that throw (`Mat4x4.inverse`, `Mat4x4.perspective`, `Mat4x4.lookAt`) can't be used in kernels.
- **WGSL builtins:** a math function whose WGSL builtin matches it declares that explicitly, in its own file (`intrinsic({ wgsl: "normalize", parameters: ["vec3f"], result: "vec3f" }, implementation)`). In a GPU kernel a call to it becomes an intrinsic region holding its operands and its inlined body: WGSL emits the builtin on vector and matrix types, nesting calls as builtins do, and JS and WASM run the body, so CPU output is unchanged. Locals keep their JS names in WGSL, and a region's result is named after its function.
@@ -266,11 +273,11 @@ Measured with `pnpm perf` (benchmarks bundled as in production, on plain node) a
- **Commands:** typed parallel arrays with explicit counts, reused per system across runs; ids resolved when issued, and each list command records its shape. Kernels issue commands through `begin`/`value`/`component` by key index, allocating nothing; the compiler gives every component of a command's list a value, `undefined` for one it leaves out, which the runtime writes as the component's start. Workers write theirs into a command ring in shared memory, structs as bytes in their storages' layout and other values as tags, which the world drains at the sync point into buffers whose struct values stay bytes in the ring, copied by word as they apply (`threads.md`).
- **Kernel spawns:** staged, not commands. The compiler lists each kernel's spawn lists, and each spawn calls its list's spawner with one value per component, generated per list as main-thread spawners are. The main thread stages into batches per runner, and each chunk's buffer names the rows staged while it was current; a worker stages per task and copies its batches into its command ring after the chunk's commands. The sync point commits staged rows in merge order, right after main-thread batches and before any command, so row order doesn't depend on which thread ran a chunk. Kernel spawners reserve entities in blocks, from 8 positions doubling to 4096, so workers don't contend for the cursor; each run or task ends its block, and the sync point returns the positions it left unused to the free list with their generations.
- **Sync point:** plans straight over the queued buffers into reusable typed arrays, through cached archetype edges (arrays by component id), per-pair move lists, and for each list add or remove a table from source archetype to target; archetypes grow once per sync point; common layouts are written inline rather than through layout closures. Applying groups entries by source and target archetype: each pair copies the columns its archetypes share column by column for its block of rows and writes derived defaults once per column, then each entry's values are written. Rows left are removed last entry first, so an archetype moving out whole drops its rows without copying them; a row moved into a hole that another entry still leaves takes that entry's place.
-- **Chunk ticks:** every storage keeps the newest `added`/`changed` tick per 1024-row chunk, stamped with every row tick, so change filters skip unchanged chunks. Stamps every row reaches are made once per kernel call, per chunk.
+- **Chunk and block ticks:** every storage keeps the newest `added`/`changed` tick per 1024-row chunk and per 64-row block, both stamped with every row tick and raised by a row copied in, in shared memory beside the row ticks, so change filters skip unchanged chunks, then the unchanged blocks of a chunk that changed: finding a chunk's passing rows costs its sixteen block ticks and the rows of the blocks that changed, not its 1024 row ticks. Writers store a row's tick, then its block's, then its chunk's, the reverse of the order filters read them. Stamps every row reaches are made once per kernel call, per block and chunk.
- **Ticks only where observed:** a component has tick columns only once some registered system filters on its `added` or `changed`, and resources always do; until then nothing allocates or stamps them, and once started they stay, bounded by the components. Columns start at the current tick, so a new system, starting with `lastRun = 0`, sees every row once. Kernels also drop `changed` stamps for components nothing watches: a count per component of the registered systems filtering on its `changed` and of the finalizers' indexes following it, an index's for the world's life. A count turning positive reloads the kernels that dropped its stamps; one falling to zero reloads nothing, and kernels drop its stamps when they next load for another reason, so a filter coming and going, as a system run once does, reloads nothing.
- **What a system holds lives as long as its value:** a world keeps its schedules and runners by system value, weakly, so a system nothing else references, such as one made for a single run, is collected with its runner, command buffers and `lastRun`, while a module's systems keep theirs between runs. What a runner holds beyond its JS values (reduction partials and `up` orders in the arena, lookup directories, GPU records, its hook after each sync point, its count of watched components) is released once its system is collected, through a finalization registry whose records hold nothing of the system. Runs read a schedule's runners again only after one was replaced or removed, as placements, device changes and `remove` do; a removed one reads as a runner that runs nothing, and a registered composite holding a system forgotten since registers again on its next run, keeping its other members' runners. Ids of keys, added resources and tick columns stay, bounded by the components; the registry forgets a collected item without an id, its label with it. `lifetime.test.ts` runs 10,000 new systems, each once, and finds every table and the heap as they were.
- **Kernels where WASM can't compile** (a content security policy without `'wasm-unsafe-eval'`) run their compiled JS.
-- **Change filters inside WASM kernels:** one call per archetype or worker chunk; chunks and rows are skipped in WASM. With SIMD, outside `up`, each aligned block of eight rows checks its ticks at once and is skipped when none passes.
+- **Change filters inside WASM kernels:** one call per archetype or worker chunk; chunks, blocks and rows are skipped in WASM. An outer loop skips chunks whose ticks aren't newer, reads a passing chunk's block ticks into a mask, and finds each run of passing blocks with `ctz`; an inner loop runs the run's rows, at one comparison a row. A chunk with more than half its blocks passing is one run. With SIMD, outside `up`, each aligned group of eight rows checks its ticks at once and is skipped when none passes.
- **Reductions in WASM kernels:** sums, products, minimums and maximums fold consecutive rows into four lanes in turn, so a fold doesn't wait on the one before.
- **Holders:** each reference component keeps the holders of each target as linked lists by entity index, updated from the plan's reference changes at Apply, so cascades find a despawned target's holders without a scan.
- **Lookups in WASM kernels:** a directory per lookup in shared memory (per archetype: a match flag, column addresses, and for `changed` each watched component's tick column, 0 without ticks), rebuilt at sync points and once a registration starts ticks; finds check index and generation against the shared entity table. A lookup with `changed` reads its system's `lastRun` after the lookup parameters, -1 once a GPU kernel stamped a whole watched column since; JS kernels on workers read it there too, and a JS kernel's result holds `changed` after its values.
@@ -284,10 +291,10 @@ Measured with `pnpm perf` (benchmarks bundled as in production, on plain node) a
- **The observer pass:** each list of observer systems keeps its runners, read again once runners change, and whether any looks up through an index, so an idle pass reads no index and no GPU state: `observer idle 100` about 80 ns an observer system a tick, and a run of observer systems alone skips its empty sync point: `run observer` 0.21 µs against 0.38–0.42 for `run idle`. One on a joined thread is posted only when its conditions hold and a filter may pass, by its resources' and matched chunks' ticks, into a block of its own, waking each thread once a pass; that thread reads the shared memory directly, the next writer copying what it hasn't read yet, or, for one reading only struct resources, reads copies the post holds, written into one of two blocks so the world never waits for it: `joined observer resource` 4.3–5.0 µs a tick and `joined observer 10K` 0.41–0.51 ms in node; 7.2–7.5 µs and 0.34–0.50 ms in chromium.
- **Indexes:**
- Keys are encoded as order-preserving words (f32 and f64 bits flipped, NaN last and -0 as +0, i32 with its sign bit flipped, booleans 0 or 1, object parts as ids interned per index), hashed by a multiply-xorshift mix, the same in JS and WASM.
- - Hash indexes: two-word slots of hash and bucket beside bucket records of hash, segment and key, so a probe reads one line until a hash matches; each bucket a segment of one entries buffer with spare slots, its entries (entity index, archetype, row) in entity order; ticks by bucket for `changed`, membership's and each watched component's. Sorted indexes: blocks of at most 128 entries in key order, then entity order, listed in key order, with a fence of each block's last key and entity beside them, a Fenwick tree of block lengths for positions, and segment trees of block ticks, so a range's `changed` is O(log blocks), and the whole index's the tree's root; entries are padded to an even number of words, so a shift within a block moves 8-byte words, and a block split or dropped refreshes positions, fences and trees from its position on. An index without a key is a sorted table with no key words, its entries in entity order: an entity entering or leaving moves at most a block's entries, found by binary search by entity, and writes to what it holds by cost it nothing, since it reads no key ticks; a watched component's change stamps one block, found by chunk ticks. `keyless refresh 1M 1%` 187 ns an entity entering or leaving, `keyless spawn 1M 1%` 105 ns an entity spawned or despawned at the end of entity order, `keyless index idle` 7 ns an index, `keyless length` 5 ns a lookup reading `length` and `changed`. All in the arena.
- - Kept current before each tier holding a user, from the journal and the ticks of key parts, which systems' writes and sync points' values mark per component, so an idle refresh reads no ticks: `index idle` 9 ns an index. Changed rows move their entries, short buckets one by one and long ones in one merge, sorted blocks split and dropped: `index refresh 1M 1%` 1.4 ms. Once the rows it lists pass an eighth of the index, it's built again.
+ - Hash indexes: two-word slots of hash and bucket beside bucket records of hash, segment and key, so a probe reads one line until a hash matches; each bucket a segment of one entries buffer with spare slots, its entries (entity index, archetype, row) in entity order; ticks by bucket for `changed`, membership's and each watched component's. A change to a bucket of at least 64 entries, in an index that isn't unique, may leave it unsorted, so moving an entity between two long buckets costs O(log bucket) to find it and O(1) amortized to move it, not a shift of the bucket: an entry leaving the part in entity order leaves a hole (archetype -1, its entity index kept for binary search) and one arriving out of order joins a tail, its position kept by entity index, leaving it by swap-remove; the record's length counts entries, not holes, so `length` and `changed` read no order. An unsorted bucket is sorted, its tail sorted aside, its holes closed and the tail merged in from the end, when a list on the world's thread first reads it by position, when the index's arrays are handed to a kernel or another thread (`writeIndexParameters`), when its segment is full and has holes, closing them in place rather than growing it, and once its holes and tail pass four times its entries; a segment an unsorted bucket outgrows gets half its length more spare slots, so sorts to make room are O(1) amortized; long moves copy 8-byte words, since shared memory copies an element at a time. `bucket move 100K length` 5.5 µs a move between two buckets of 50K and a run reading both lengths, against 423 µs kept in order; `bucket move 100K in order` 69 µs, both buckets sorted again for a read of their first rows. Sorted indexes: blocks of at most 128 entries in key order, then entity order, listed in key order, with a fence of each block's last key and entity beside them, a Fenwick tree of block lengths for positions, and segment trees of block ticks, so a range's `changed` is O(log blocks), and the whole index's the tree's root; entries are padded to an even number of words, so a shift within a block moves 8-byte words, and a block split or dropped refreshes positions, fences and trees from its position on. An index without a key is a sorted table with no key words, its entries in entity order: an entity entering or leaving moves at most a block's entries, found by binary search by entity, and writes to what it holds by cost it nothing, since it reads no key ticks; a watched component's change stamps one block, found by chunk ticks. `keyless refresh 1M 1%` 187 ns an entity entering or leaving, `keyless spawn 1M 1%` 105 ns an entity spawned or despawned at the end of entity order, `keyless index idle` 7 ns an index, `keyless length` 5 ns a lookup reading `length` and `changed`. All in the arena.
+ - Kept current before each tier holding a user, from the journal and the ticks of key parts, which systems' writes and sync points' values mark per component, so an idle refresh reads no ticks: `index idle` 9 ns an index. Changed rows move their entries, short buckets kept in entity order and long ones left unsorted, sorted blocks split and dropped: `index refresh 1M 1%` 1.4 ms. Once the rows it lists pass an eighth of the index, it's built again.
- Built again in four phases over fixed pieces of entity indexes and fixed partitions of the slots, so the index is the same whatever the number of workers: counting each piece's members by partition, listing them into partition order through code generated for the key's leaves, grouping each partition's keys into buckets, and filling its range of the table. Workers claim phases through their task rings; with no pool the world's thread runs them: `index rebuild 1M` 27 ms on the world's thread, 6.7 ms across nine workers. Sorted indexes are built on the world's thread, by LSD radix sort over 16-bit digits.
- - Main-thread lookups: the plugin rewrites each call into a site of its own, literal tuples and comparisons flattened into scalar arguments; a hash site's encoding, hash and probe are generated code, a loop where a content security policy forbids it. Lists, and the rows they give, share one hidden class each, their accessors the same functions reading their state through `this`; a site's lookup makes its rows ahead of `at`, for a list's first 256 positions and a sorted list's last 256, and on the world's thread rows check one count of replaced arrays rather than their archetype's version: `unique lookup 1M` 44 ns a lookup, `children lookup` 13 ns a child, `sorted range 1M` 0.56 µs a range of ten, `sorted page 100K` 5 µs a change against 1.1 ms for `select page 100K`.
+ - Main-thread lookups: the plugin rewrites each call into a site of its own, literal tuples and comparisons flattened into scalar arguments; a hash site's encoding, hash and probe are generated code, a loop where a content security policy forbids it. Lists, and the rows they give, share one hidden class each, their accessors the same functions reading their state through `this`; a site's lookup makes its rows ahead of `at`, for a list's first 256 positions and a sorted list's last 256, and rows check one count of replaced arrays rather than their archetype's version, on a joined thread settled once a run: `unique lookup 1M` 44 ns a lookup, `children lookup` 13 ns a child, `sorted range 1M` 0.56 µs a range of ten, `sorted page 100K` 5 µs a change against 1.1 ms for `select page 100K`.
- Kernels compile each lookup call into a probe the world binds to its index's layout; WASM probes in straight-line code, reading the index's arrays at addresses each run's parameters give, on the world's thread and pool workers; joined threads read a record of them the world writes before each task: `grid neighbors 100K` 3.7 µs a row on the world's thread, 179 ns a row in a kernel across the pool.
- `order`: main-thread bodies walk the index's entries; filtered runs sort passing rows' positions while few pass and scan past an eighth; kernels run lists of rows as `up`'s do: `order run 100K` 31 ns a row against 28 in storage order.
- **`up`:**
@@ -295,7 +302,7 @@ Measured with `pnpm perf` (benchmarks bundled as in production, on plain node) a
- Each system keeps a tree by entity index (parent, level, children as linked lists), read from the journal; levels settle by walking up to a root or a settled entity, which also finds cycles.
- The order: groups of one depth, archetype and parent archetype, as segments of one arena buffer with spare slots, patched in two phases by binary search and block moves within a group, so rows keep storage order.
- Rebuilt in full, in O(rows), when the journal dropped entries a system hadn't read, or when more than a quarter of the order's rows would move.
- - Filtered runs mark entities into lists by depth, from chunk ticks and the journal; a depth with few marks is sorted, one with many is scanned in order.
+ - Filtered runs mark entities into lists by depth, from chunk and block ticks and the journal; a depth with few marks is sorted, one with many is scanned in order.
- WASM kernels run a group's row list, with the parent archetype's column addresses as parameters.
- Workers take one depth at a time as claimed pieces, with a barrier per depth; depths too small to split run on the main thread without a wait.
- The GPU runs a dispatch per group, in order, within one pass; row lists live in the heap and upload only when the order changes.
@@ -326,7 +333,7 @@ Measured with `pnpm perf` (benchmarks bundled as in production, on plain node) a
- One dispatch over several archetypes, and indirect draws; heap segments past `maxBufferSize`; automatic placement.
- Finalizers on components ahead on the GPU: until then, the sync point reads them back first.
- Read back only the rows a sync point moves, rather than whole disturbed archetypes, or move them on the GPU.
-- **GPU-only components, not planned until a workload needs one.** A const meta tag on the component entity (components are entities), so its values have a column in the GPU heap and none in the CPU arena; entities, archetypes and `has`/`not` stay on the CPU. Values given at spawn are uploaded once; otherwise rows start at the schema default.
+- **GPU-only components, not planned until a workload needs one.** A const meta tag on the component entity (components are entities), so its values have a column in the GPU heap and none in the CPU arena; entities, archetypes and `has`/`not` stay on the CPU. Values given at spawn are uploaded once; otherwise rows start at their start.
- **The use case:** GPU state with history that nothing on the CPU reads, such as a simulation integrated only on the GPU, previous-frame transforms for motion vectors, or accumulators. Data recomputed every frame (culled lists, indirect draw arguments, sort keys) isn't state: render systems own those as scratch buffers. Cached derived data (world matrices, skinning palettes) is an optimization, not a need.
- **What it adds over an ordinary component written only on the GPU,** which already stays GPU-resident and is never read back unless observed: the CPU column's memory, and a type-level guarantee that CPU systems and observers can't name it.
- **What it requires:** structural changes on the GPU (the move kernel above), since there's no CPU copy to move.
diff --git a/packages/ecs/followups.md b/packages/ecs/followups.md
index 566e8c1e6..e6b893440 100644
--- a/packages/ecs/followups.md
+++ b/packages/ecs/followups.md
@@ -1,11 +1,93 @@
# ecs follow-ups
-Open items awaiting a decision or work. Remove an item once it's decided or done.
+Open items awaiting a decision or work, by priority: the API is the focus while it's being finalized; then
+correctness, then optimization. Remove an item once it's decided or done.
+
+## API changes
+
+- **Run results: a run resolves to its body's value; reports leave the public API** (agreed in design, not started).
+ `run(system, input): Promise`, R the per-run body's return type (`void` for row systems, composites and bodies
+ returning nothing), so one-off reads are observer systems returning a value. A run whose systems failed rejects
+ after its sync point applies, with a useful error naming each failure; its exact shape is no contract. Ticks'
+ failures are rethrown out of band. `Report`, its archetype and types, and run numbers leave the public API; the
+ implementation keeps or drops reports as performance dictates. A later `World.create` options parameter may take
+ instrumentation (timing, failure logs, debugging); nothing until it's designed.
+- **Nothing tells the world's thread a join finished.** After `World.join`, a run from the world's own thread can run
+ before the joined thread has claimed its threads, so that thread's observers miss it; the guide's threads section
+ runs from the joined world instead. Options: `World.join` resolving once claimed (already true on the joined side?),
+ or `world.share` returning a promise of the claim. Smallest form to be decided. A refused claim, such as one of a
+ thread an id-less finalizer runs on, shows the world's thread only `"gone"` and the joined thread only a rejected
+ first run, so a helper waiting for `"joined"` waits forever.
+- **Optional components in GPU kernels:** each dispatch's record could carry a presence word, but WGSL has no
+ `undefined` to read where an archetype lacks one, and a GPU kernel can't fail its system for one row; supporting
+ them means a presence expression through the WASM and WGSL backends and a GPU semantics for absent reads and writes.
+- **Optional groups:** components always present together (boards' text's four, path's three) must each be tested
+ for `undefined` before building one object. Possible: an optional group, present or absent as a whole, narrowed by
+ one test. Boards isn't asking for it; record usage before deciding.
+- **Iterable lists:** `for (const row of list)` on index lookups' lists, binding each row as the loop reaches it with
+ no allocation (the list is its own iterator, reset per loop), lowered by kernels to the indexed loop. `list[i]` and
+ array methods stay out: indexing can't bind lazily without a Proxy, and the methods would widen the surface.
+- **`watch` by trait on changes finalizers:** `Finalizer.changes({ watch: "persist" }, …)`, `watch` taking a trait name as
+ well as a list of keys, typed as `Component.With<…>`, computed once per component id as `watches` already is. The
+ hook external persistence would build on.
+- **Observer systems can't declare `up`**, and `order` can't be combined with `up`, so views as separate entities
+ don't work as bindings. Proposal: allow `up` in observer systems, since it only reads; let `order` take an index over
+ the entity a reference names. (from the todo sample)
+- **Per-row CPU values in render systems:** a render body's tables are `{ count, bind }`, so it can't draw with a
+ per-row object such as a texture's bind group, and a row body can't learn its GPU row. A consumer drawing an image
+ per row keeps its own instance buffer, 4,000 draws in 2.1 ms a tick. Possible: tables giving their entities in GPU
+ row order. Not needed until a consumer's profile says so.
+- **Descending sorted indexes:** an ordered render reads its index front to back, so a consumer painting back to front
+ maps `instance_index` to `ecs_count() - 1u - instance_index`, which costs nothing. Possible: a `descending` option on
+ sorted indexes. Not needed until a consumer's profile or readability says so.
+- **Report retention:** the last 256 runs keep their reports (`retainedRuns` in `reports.ts`), a fixed internal
+ constant; a later run takes the oldest one's entity. Recommended: keep it internal until a consumer needs another; then
+ a world option or resource.
+- **Report components are writable:** systems may write or despawn reports, which are ordinary entities; a despawned
+ one is spawned again for a later run. Recommended: accept, as with any entity; or make them world-written as `clock`
+ is.
+- **Computed index keys:** an index keyed on a value computed from a component, such as a struct field
+ (`position.x`, `Report.run.number`), instead of only whole components. Two possible forms:
+ `key: [[Report.run, "number"]]` (a field path) or `keyOf: run => run.number` (a function). Open questions:
+ - how key words are laid out in shared memory;
+ - lookups on workers, on joined threads and in kernels (JS and WASM);
+ - change tracking that follows the field, not the whole component;
+ - whether a function form can run anywhere but the world's thread.
+- **Discriminated struct unions:** unions of structs with a number, boolean or string-literal discriminant, stored in
+ fixed size (a tag column plus the variants' fields) so they live in shared memory. Today `isStruct`
+ (`interface/registry/schemas.ts`) treats every `anyOf` as an object. The work covers row readers and writers,
+ narrowing on the tag in the kernel compiler, WGSL layouts, and snapshots. Check first whether the type-level
+ `IsStruct` already calls numeric-tagged unions structs while the runtime doesn't.
+- **The `Finalizer` namespace** (from earlier open work; to be restated before it's discussed).
+
+## Correctness
+
+- **Transactions, future:** never by default, since they would cost every run. An explicit declarative option on a
+ one-off system, making its run all or nothing.
+
+## Optimization
+
+- **A run allocates about 3.6 KB even for an empty system,** in node: the run path's async functions (`executeTiers`,
+ `perform`, `execute`, `runWith`), their promises and a few small objects. Make the path allocate nothing when nothing
+ waits, and measure with a sampling heap profile.
+- **`transfer`, chromium:** three runs on a quiet machine gave 19.9, 22.5 and 16.1 ms, so its spread of about 20% is
+ its own, not the machine's. Find what varies (likely the time a worker takes to wake) before treating its changes
+ as signal.
+- Index step 5 items.
+- Starting the pool for large index builds.
+- `order` on workers.
+- Chromium timer clamps.
+- The `transfer` cost.
+- The perf.md "Next" items.
-## Queued (decided, not started)
+## External utilities
+- **Persistence,** never part of the core: provided externally through custom traits (e.g. `persist: string`, the stable
+ name saved data maps through, independent of thread ids) and changes finalizers watching by trait.
+- **A test helper awaiting the next tick.** Tests watch `clock` with an observer system today; a helper in
+ `src/observe` would do it (from the todo sample).
-## Decisions for the maintainers
+## Decisions awaiting confirmation
- **An index lookup's `changed: true` is a change filter,** as a resource in `changed` is: when the index changed every
row passes, and otherwise rows pass by the system's other change filters, so with `components.changed` it runs the
@@ -29,31 +111,13 @@ Open items awaiting a decision or work. Remove an item once it's decided or done
- **Filtering in the todo sample:** it writes `hidden` on each row (about 2 ms of script at 10K) rather than setting one
attribute on the list and hiding rows with CSS. The attribute version is slower once laid out (25 ms against 17 ms
at 10K) and puts the filter's meaning in the list element.
-- **One-off reads:** `world.run` returns no value, so a one-off read needs an observer system that writes into an outer
- variable.
-- **Per-row CPU values in render systems:** a render body's tables are `{ count, bind }`, so it can't draw with a
- per-row object such as a texture's bind group, and a row body can't learn its GPU row. A consumer drawing an image
- per row keeps its own instance buffer, 4,000 draws in 2.1 ms a tick. Possible: tables giving their entities in GPU
- row order. Not needed until a consumer's profile says so.
-- **Paint order across render tables:** a render system draws each matching archetype as its own table in row order,
- so 2D content of several kinds (shapes, images, paths) can't be blended back to front without the consumer keeping
- its own sorted instance list. Proposal: `order: index` on `System.render`, giving one table spanning its archetypes
- whose `bind` sets a buffer of the index's rows in sorted order with each archetype's column base addresses; every
- column already lives in one heap, so accessors resolve a sorted instance to its row. Not needed while consumers draw
- every kind with one shader from their own list.
-- **Report retention:** the last 256 runs keep their reports (`retainedRuns` in `reports.ts`), a fixed internal
- constant; a later run takes the oldest one's entity. Recommended: keep it internal until a consumer needs another; then
- a world option or resource.
- **The cost of a report for every run:** a clean run costs about 125 ns more in node (`run idle` 333 to 458 ns, `run
observer` 166 to 291), half of it the two clock reads `elapsed` takes. Decided: timing stays on. Check again if runs
get cheap enough for it to matter: a `Report.timing` resource, off by default with `elapsed` NaN, measured `run idle`
416 ns and `run observer` 208.
- **Reports are hidden from queries that require no component,** as the resource entity is: an index or system with no
- `has` or `read`, and changes finalizers watching everything, don't see them. Recommended: accept, since they're the
+ `with`, and changes finalizers watching everything, don't see them. Recommended: accept, since they're the
world's own.
-- **Report components are writable:** systems may write or despawn reports, which are ordinary entities; a despawned
- one is spawned again for a later run. Recommended: accept, as with any entity; or make them world-written as `clock`
- is.
- **Errors while tails run:** an add or removal of `Report.errors` found after a run's sync point waits for the next
sync point while tails run on a joined thread, an earlier run's errors emptied meanwhile. Recommended: accept; errors
known before the sync point, system failures, skipped systems and warnings, are in place when the run resolves.
@@ -82,65 +146,3 @@ Open items awaiting a decision or work. Remove an item once it's decided or done
- **An observer system on a joined thread that reads a string resource doesn't post copies of its resources:** nothing
waits for such posts, and a handle there could outlive its record, so it posts as row systems do. Recommended: accept.
-## Findings from the todo sample (`src/samples/todo/README.md`)
-
-2. **Observer systems can't declare `up`**, and `order` can't be combined with `up`, so views as separate entities
- don't work as bindings. Proposal: allow `up` in observer systems, since it only reads; let `order` take an index over
- the entity a reference names.
-4. **Low-cardinality hash indexes cost O(bucket) per change.** Buckets are kept in entity order and shifted on removal:
- one toggle costs 26, 62 and 400 µs at 1K, 10K and 100K. Proposal: unordered buckets with swap-remove and a position
- map, or a count-only index.
-5. **Change filters scan the whole dirty chunk** (1,024 row ticks), once per system that filters on `changed`.
- Proposal: a second level of change ticks, one per 32–64 rows.
-6. **Nothing awaits the next tick.** Tests watch `clock` with an observer system. Proposal: a test helper in
- `src/observe`.
-
-## Findings from strings in shared memory
-
-- **A joined finalizer read a resource of its thread as undefined:** in `remote-finalizers.test.ts`, "that throw are in
- a later run's report", the finalizer's `resources[tone]`, an object of its thread, is undefined for the stops of a
- sync point soon after the thread joined, where the same finalizer reads it in the other cases; the fixture reads it
- optionally now. The test checks no tone, so whether it was undefined before, as a string, wasn't seen. Worth finding
- out whether a remote finalizer can run before its thread's resources reach its mirror.
-
-## Features to discuss
-
-- **Computed index keys:** an index keyed on a value computed from a component, such as a struct field
- (`position.x`, `Report.run.number`), instead of only whole components. Two possible forms:
- `key: [[Report.run, "number"]]` (a field path) or `keyOf: run => run.number` (a function). Open questions:
- - how key words are laid out in shared memory;
- - lookups on workers, on joined threads and in kernels (JS and WASM);
- - change tracking that follows the field, not the whole component;
- - whether a function form can run anywhere but the world's thread.
-- **Discriminated struct unions:** unions of structs with a number, boolean or string-literal discriminant, stored in
- fixed size (a tag column plus the variants' fields) so they live in shared memory. Today `isStruct`
- (`interface/registry/schemas.ts`) treats every `anyOf` as an object. The work covers row readers and writers,
- narrowing on the tag in the kernel compiler, WGSL layouts, and snapshots. Check first whether the type-level
- `IsStruct` already calls numeric-tagged unions structs while the runtime doesn't.
-
-## Performance
-
-- **The filtered `up` benchmarks are slower than this machine's record at `e3c30aa1`:** `up one root changed` +49%,
- `up filtered after a reparent` +45%, `up small tree in a big world` +79%. They're the same at `c6bb3c83`, before
- strings, so the drift is older. Worth bisecting from `e3c30aa1`.
-
-- **A joined thread's first tail task once never finished,** under heavy machine load (two test suites at once): the
- world waited on `channel.idle()` with task 1 posted and not done (`threads.browser.test.ts`, tails "see one tick's
- values"). It didn't recur in 25 runs without the extra load, and the base failed once on another threads test in the
- same comparison. Worth a stress run of the joined-thread suites under load.
-
-- **`transfer`, chromium:** its median varied with machine load. Check it alone on a quiet machine before the next
- record.
-- **`joined observer 10K`:** checking archetype versions once per post instead of per row would save some of the
- thread's run (see perf.md "Next").
-
-## Earlier open work
-
-- Index step 5 items.
-- Starting the pool for large index builds.
-- `order` on workers.
-- Typed transfers (Phase 7).
-- The `Finalizer` namespace.
-- Chromium timer clamps.
-- The `transfer` cost.
-- The perf.md "Next" items.
diff --git a/packages/ecs/memory.md b/packages/ecs/memory.md
index be03816b5..78e6a12a8 100644
--- a/packages/ecs/memory.md
+++ b/packages/ecs/memory.md
@@ -5,6 +5,12 @@ decisions about ecs here rather than in a personal memory, so every machine and
## Interface
+- **A minimal, abstract surface, as WebGPU's is.** Expose the smallest common surface that says what consumers mean,
+ and nothing about how it's done, so the implementation stays free to change and optimize. Prefer what the build can
+ derive over what consumers write; never expose a mechanism consumers could call instead of declaring intent, such
+ as registration.
+- **Agents write most consumer code; type errors guide them, editor affordances don't.** Spend effort on precise types
+ and build errors, not on completions, editor plugins or other tooling only an IDE shows.
- **Humans own the interface, agents own the implementation** (`src/interface/CLAUDE.md`). Agents add or widen nothing
public: no exported types, built-in components or resources, fields, options, union members or observable behaviour.
Any change that can't be avoided is proposed for review before it lands.
diff --git a/packages/ecs/package.json b/packages/ecs/package.json
index 8c605e03d..279400ed2 100644
--- a/packages/ecs/package.json
+++ b/packages/ecs/package.json
@@ -1,11 +1,14 @@
{
"name": "@adobe/ecs",
- "version": "0.1.0",
+ "version": "0.1.1",
"description": "Adobe ECS",
"license": "MIT",
"type": "module",
"sideEffects": false,
"private": false,
+ "pages": [
+ { "from": "docs", "to": "ecs" }
+ ],
"independentVersion": true,
"publishConfig": {
"registry": "https://registry.npmjs.org/",
@@ -30,14 +33,15 @@
],
"scripts": {
"build": "cp ../../LICENSE . && tsc -b",
- "dev": "tsc -b -w --preserveWatchOutput",
+ "dev": "tsc -b -w --preserveWatchOutput & vite --config vite.sample.config.ts",
"typecheck": "tsc -b",
"test": "pnpm exec playwright install chromium && vitest --run",
"test:perf": "pnpm exec playwright install chromium && vitest --run --config vitest.perf.config.ts",
"perf": "node scripts/perf.mjs",
"perf:browser": "node scripts/perf.mjs --browser",
"perf:report": "node scripts/perf.mjs --report",
- "sample": "vite --config vite.sample.config.ts"
+ "sample": "vite --config vite.sample.config.ts",
+ "docs": "typedoc"
},
"devDependencies": {
"@types/estree": "^1.0.5",
@@ -45,6 +49,7 @@
"@vitest/browser-playwright": "^5.0.5",
"@webgpu/types": "^0.1.61",
"playwright": "^1.64.1",
+ "typedoc": "^0.28.9",
"typescript": "^5.8.3",
"vite": "^8.3.4",
"vitest": "^5.0.5"
diff --git a/packages/ecs/scripts/typedoc-guide-links.mjs b/packages/ecs/scripts/typedoc-guide-links.mjs
new file mode 100644
index 000000000..6b3121086
--- /dev/null
+++ b/packages/ecs/scripts/typedoc-guide-links.mjs
@@ -0,0 +1,23 @@
+// © 2026 Adobe. MIT License. See /LICENSE for details.
+// Points the guide's links to the tests its excerpts come from at those files on GitHub, where they render with line
+// highlights, instead of at copies typedoc would serve as raw files.
+import { rmSync } from "node:fs";
+import { join } from "node:path";
+import { MarkdownEvent, RendererEvent } from "typedoc";
+
+const source = "https://github.com/adobe/data/blob/main/packages/ecs/src/guide/";
+const copied = /href="(?:\.\.\/)*media\/([\w.-]+\.ts)(#L\d+(?:-L\d+)?)?"/g;
+
+/** @param {import("typedoc").Application} app */
+export const load = app => {
+ const files = new Set();
+ app.renderer.on(MarkdownEvent.PARSE, event => {
+ event.parsedText = event.parsedText.replace(copied, (_, file, lines = "") => {
+ files.add(file);
+ return `href="${source}${file}${lines}"`;
+ });
+ });
+ app.renderer.on(RendererEvent.END, event => {
+ for (const file of files) rmSync(join(event.outputDirectory, "media", file), { force: true });
+ });
+};
diff --git a/packages/ecs/src/exports.test.ts b/packages/ecs/src/exports.test.ts
index 161675a29..6624b0c6b 100644
--- a/packages/ecs/src/exports.test.ts
+++ b/packages/ecs/src/exports.test.ts
@@ -29,31 +29,28 @@ const install = (): void => {
};
const consumer = `
-import { Archetype, Component, Index, Rule, System, World, gpuDevice, workers } from "@adobe/ecs";
+import { Index, Rule, System, World, gpuDevice, workers } from "@adobe/ecs";
export const position = Symbol("exportsTest/position");
export const element = Symbol("exportsTest/element");
export const shown = Symbol("exportsTest/shown");
declare module "@adobe/ecs" {
interface Components { [position]: { type: number }; [element]: { type: HTMLElement; thread: "main" }; [shown]: { type: true; derived: true } }
}
-Component.register(position, { type: { type: "number" } });
-Component.register(element, { thread: "main" });
-Component.register(shown, { type: { const: true }, derived: true });
-export const Placed = Archetype.create([position, element]);
-export const showing = Rule.create({ components: { has: [element] }, derive: [shown] });
-export const move = System.create({ name: "exportsTest/move", components: { write: [position] } }, row => { row[position] += 1; });
-export const all = System.create({ name: "exportsTest/all", systems: [move] });
-export const draw = System.render({ name: "exportsTest/draw", shader: { accessors: {} } }, () => {});
-export const placed = Index.create({ name: "exportsTest/placed", key: [position], components: { has: [element] } });
+export const Placed = [position, element] as const;
+export const showing = Rule.create({ components: { with: [element] }, derive: [shown] });
+export const move = System.create({ components: { with: [position] } }, row => { row[position] += 1; });
+export const all = System.create({ systems: [move] });
+export const draw = System.render({ shader: { accessors: {} } }, () => {});
+export const placed = Index.create({ key: [position], components: { with: [element] } });
export const ranked = Index.create({ key: [position], sorted: true });
-export const after = System.create({ components: { read: [position] }, lookup: { next: { index: ranked, read: [position] } } }, (row, { lookup }) => { void lookup.next({ ">": row[position] }).length; });
+export const after = System.create({ components: { with: [position] }, lookup: { next: { index: ranked } } }, (row, { lookup }) => { void lookup.next({ ">": row[position] }).length; });
export const sortedName = (index: Index<{ readonly sorted: true }>): string => index.name;
// A world comes from the one import every other value does.
export const world: World = World.create({ [workers]: 0 });
// Another thread joins it through the other end of a channel the world shares.
declare const port: MessagePort;
export const joined: World = World.join(port, { threads: ["main"] });
-export const read = System.create({ observer: true, components: { read: [position] }, resources: { read: [gpuDevice] }, lookup: { at: { read: [position] } } }, () => {});
+export const read = System.create({ observer: true, components: { with: [position] }, lookup: { at: { with: [position] } } }, () => {});
export const added = world.add(read);
`;
@@ -78,7 +75,7 @@ const internal = (path: string): boolean =>
|| path.startsWith("interface/gpu/record");
/** Compiles a consumer's `index.ts` against the installed package, emitting its declarations. */
-const compile = (source: string): { readonly messages: string[] } => {
+const compile = (source: string): { readonly messages: string[]; readonly lines: number[] } => {
writeFileSync(join(temporary, "index.ts"), source);
const program = ts.createProgram([join(temporary, "index.ts")], {
target: ts.ScriptTarget.ESNext,
@@ -92,36 +89,82 @@ const compile = (source: string): { readonly messages: string[] } => {
outDir: join(temporary, "out"),
});
const result = program.emit();
- return { messages: messages([...ts.getPreEmitDiagnostics(program), ...result.diagnostics]) };
+ const diagnostics = [...ts.getPreEmitDiagnostics(program), ...result.diagnostics];
+ // Each diagnostic's line in the consumer, 1-based; 0 for one outside it.
+ const lines = diagnostics.map(diagnostic => diagnostic.file === undefined || diagnostic.start === undefined || !diagnostic.file.fileName.endsWith("index.ts")
+ ? 0
+ : diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start).line + 1);
+ return { messages: messages(diagnostics), lines };
};
-/** A consumer that registers a symbol-keyed component beside the augmentation given. */
-const stringKeyed = (augmentation: string, registration = ""): string => `
-import { Component, Trait } from "@adobe/ecs";
-export const keyed = Symbol("exportsTest/keyed");
-declare module "@adobe/ecs" {
- interface Components { [keyed]: { type: number } }
- ${augmentation}
-}
-Component.register(keyed, { type: { type: "number" } });
-${registration}
-`;
+/** The 1-based lines of `source` holding `text`. */
+const linesWith = (source: string, text: string): number[] =>
+ source.split("\n").flatMap((line, index) => line.includes(text) ? [index + 1] : []);
/** A consumer with a system whose declaration lists a key in both read and write, and composites of conflicting inputs. */
const misdeclared = `
-import { Component, System } from "@adobe/ecs";
+import { System } from "@adobe/ecs";
export const health = Symbol("exportsTest/health");
declare module "@adobe/ecs" {
interface Components { [health]: { type: number } }
}
-Component.register(health, { type: { type: "number" } });
-export const both = System.create({ components: { read: [health], write: [health] } }, () => {});
-const near = System.create({ input: { type: "object", properties: { distance: { type: "number" } } } }, () => {});
-const far = System.create({ input: { type: "object", properties: { distance: { type: "string" } } } }, () => {});
+// What a body reads is inferred, never declared.
+export const both = System.create({ components: { read: [health] } }, () => {});
+const near = System.create({}, (_context, input: { readonly distance: number }) => { void input; });
+const far = System.create({}, (_context, input: { readonly distance: string }) => { void input; });
export const listed = System.create([near, far]);
export const declared = System.create({ systems: [near, far], after: [both] });
`;
+/** A consumer creating two worlds and using them, with `declarations` augmenting the package. */
+const creating = (declarations: string): string => `
+import { World, workers, type Component } from "@adobe/ecs";
+${declarations}
+export const world: World = World.create();
+export const other: World = World.create({ [workers]: 0 });
+export const typed: World = world;
+export const disposed = other.dispose();
+`;
+
+/** A trait of your own on a key its type doesn't allow. */
+const customTrait = creating(`
+export const instanced = Symbol();
+export const foo = Symbol();
+declare module "@adobe/ecs" {
+ interface Traits { readonly [instanced]: N extends Component.Key ? ([Component.Value] extends [readonly number[]] ? true : never) : never }
+ interface Components { [foo]: { type: string; [instanced]: true } }
+}`);
+
+/** A built-in trait on a key its type doesn't allow: a thread on a struct. */
+const builtInTrait = creating(`
+export const spot = Symbol();
+declare module "@adobe/ecs" {
+ interface Components { [spot]: { type: number; thread: "main" } }
+}`);
+
+/** A default that isn't of the declared type. */
+const wrongDefault = creating(`
+export const label = Symbol();
+declare module "@adobe/ecs" {
+ interface Resources { [label]: { type: string; default: 1 } }
+}`);
+
+/** One key with several members that don't hold: a custom trait and a thread on a string. */
+const severalTraits = creating(`
+export const instanced = Symbol();
+export const name = Symbol();
+declare module "@adobe/ecs" {
+ interface Traits { readonly [instanced]: N extends Component.Key ? ([Component.Value] extends [readonly number[]] ? true : never) : never }
+ interface Components { [name]: { type: string; [instanced]: true; thread: "main" } }
+}`);
+
+/** One key whose default is of another type and which can't be transferred. */
+const defaultAndTransfer = creating(`
+export const canvas = Symbol();
+declare module "@adobe/ecs" {
+ interface Resources { [canvas]: { type: string; default: 1; transfer: true } }
+}`);
+
describe("package exports", () => {
beforeAll(() => {
install();
@@ -133,10 +176,9 @@ describe("package exports", () => {
// An index's type reads as it was declared, naming only the package root.
const emitted = readFileSync(join(temporary, "out", "index.d.ts"), "utf8");
expect(emitted).toContain(`export declare const placed: Index<{
- readonly name: "exportsTest/placed";
readonly key: readonly [typeof position];
readonly components: {
- readonly has: readonly [typeof element];
+ readonly with: readonly [typeof element];
};
}>;`);
expect(emitted).toContain(`export declare const ranked: Index<{
@@ -145,21 +187,39 @@ describe("package exports", () => {
}>;`);
expect([...emitted.matchAll(/import\("([^"]+)"\)/g)].map(match => match[1]).filter(path => path !== "@adobe/ecs")).toEqual([]);
}, 60_000);
+ it("report a declaration that doesn't hold once at each World.create call, naming the key, the member and the problem", () => {
+ const cases = [
+ { source: customTrait, named: `readonly [foo]: { readonly [instanced]: "isn't allowed on this key"; }` },
+ { source: builtInTrait, named: `readonly [spot]: { readonly thread: "isn't allowed on this key"; }` },
+ { source: wrongDefault, named: `readonly [label]: { readonly default: "isn't of the declared type"; }` },
+ ];
+ for (const { source, named } of cases) {
+ const { messages: reported, lines } = compile(source);
+ // One error per call, on the call; uses of the worlds type-check.
+ expect(lines).toEqual(linesWith(source, "World.create("));
+ for (const message of reported) {
+ expect(message).toContain("declarations don't hold");
+ expect(message).toContain(named);
+ }
+ }
+ expect(compile(creating("")).messages).toEqual([]);
+ }, 60_000);
+ it("name every member of a key that doesn't hold", () => {
+ const cases = [
+ { source: severalTraits, named: [`readonly [instanced]: "isn't allowed on this key"`, `readonly thread: "isn't allowed on this key"`] },
+ { source: defaultAndTransfer, named: [`readonly default: "isn't of the declared type"`, `readonly transfer: "isn't allowed on this key"`] },
+ ];
+ for (const { source, named } of cases) {
+ const { messages: reported, lines } = compile(source);
+ expect(lines).toEqual(linesWith(source, "World.create("));
+ for (const message of reported) for (const member of named) expect(message).toContain(member);
+ }
+ }, 60_000);
it("report a misdeclared system, and a composite of conflicting members, at the mistake rather than as no overload matching", () => {
const reported = compile(misdeclared).messages;
expect(reported.some(message => message.includes("No overload matches"))).toBe(false);
expect(reported.filter(message => message.includes(`has a different type in another member of this composite.`))).toHaveLength(4);
- expect(reported.some(message => message.startsWith("Type '[unique symbol]' is not assignable"))).toBe(true);
- }, 60_000);
- it("fail every registration to compile while a component, resource or trait is keyed by a string, naming the keys", () => {
- expect(compile(stringKeyed(`interface Components { "exportsTest/named": { type: number } }`)).messages.join("\n"))
- .toContain(`'{ readonly "component keys must be symbols": "exportsTest/named"; }'`);
- expect(compile(stringKeyed(`interface Resources { "exportsTest/time": { type: number } }`)).messages.join("\n"))
- .toContain(`'{ readonly "resource keys must be symbols": "exportsTest/time"; }'`);
- const trait = compile(stringKeyed(`interface Traits { readonly persisted: true }`, `Trait.register("persisted");`)).messages.join("\n");
- expect(trait).toContain(`'{ readonly "trait keys must be symbols": "persisted"; }'`);
- // Registration takes only symbol keys, so a string-keyed trait can't be registered either.
- expect(trait).toContain(`Argument of type '"persisted"' is not assignable`);
+ expect(reported.some(message => message.startsWith("Type 'symbol[]' is not assignable to type 'undefined'"))).toBe(true);
}, 60_000);
});
diff --git a/packages/ecs/src/guide/01-resources.test.ts b/packages/ecs/src/guide/01-resources.test.ts
index 8e19e05ba..19544a5c3 100644
--- a/packages/ecs/src/guide/01-resources.test.ts
+++ b/packages/ecs/src/guide/01-resources.test.ts
@@ -1,36 +1,44 @@
// © 2026 Adobe. MIT License. See /LICENSE for details.
import { expect, it } from "vitest";
-import { Entity, Resource, System, World } from "../index.js";
+import { Entity, System, World, type F32 } from "../index.js";
+import { Resource } from "@adobe/ecs";
// A resource is a key whose value a world holds once.
-export const gravity = Symbol("gravity");
-export const names = Symbol("names");
-export const audio = Symbol("audio");
-export const focused = Symbol("focused");
+export const gravity = Symbol();
+export const floor = Symbol();
+export const names = Symbol();
+export const audio = Symbol();
+export const focused = Symbol();
-// Its traits are declared; `type` is the value's type.
+// A type's namespace may give the value it starts at, which every row and world starting from it shares, so readonly.
+export type Height = F32;
+export const Height = { default: -Infinity } as const;
+
+// It's declared once, with its traits: `type` is the value's type, and `default` a literal it starts at. Without one,
+// it starts at its type's namespace's default, a reduction's identity, null, its zero value (0, false, "", item by
+// item), or an empty ReadonlyMap or ReadonlySet of each world's own. The build registers what's declared.
declare module "@adobe/ecs" {
interface Resources {
- [gravity]: { type: number };
+ [gravity]: { type: number; default: -9.8 };
+ [floor]: { type: Height };
[names]: { type: ReadonlyMap };
+ // A value that isn't data, such as a class, is stored as it is; a change inside it is seen once it's written back.
[audio]: { type: AudioContext | null };
+ // Entity.none, 0, rather than null, so an entity stays data every thread reads.
[focused]: { type: Entity };
}
}
-// Registration gives the same traits, with a schema that describes exactly the declared type. A resource always has a
-// value: its schema's default, or one beside it.
-Resource.register(gravity, { type: { type: "number", default: -9.8 } });
-Resource.register(names, { type: { type: "map", keys: { type: "string" }, values: { type: "number" } }, default: new Map() });
-// A value that isn't data has no schema and is stored as it is. A type that allows null starts there.
-Resource.register(audio, {});
-// No entity is Entity.none rather than null, so an entity stays data every thread reads.
-Resource.register(focused, { type: { ...Entity.schema, default: Entity.none } });
-
// Systems read a world: an observer only reads, after the world's changes apply.
-let values: readonly unknown[] = [];
-const read = System.create({ observer: true, resources: { read: [gravity, names, audio, focused] } }, ({ resources }) => {
- values = [resources[gravity], resources[names], resources[audio], resources[focused]];
+let values: readonly [
+ Resource.Value,
+ Resource.Value,
+ Resource.Value,
+ Resource.Value,
+ Resource.Value
+] | null = null;
+const read = System.create({ observer: true }, ({ resources }) => {
+ values = [resources[gravity], resources[floor], resources[names], resources[audio], resources[focused]];
});
it("holds one value per world", async () => {
@@ -39,7 +47,7 @@ it("holds one value per world", async () => {
await using moon = World.create({ [gravity]: -1.6 });
// run: runs a system once.
await earth.run(read);
- expect(values).toEqual([-9.8, new Map(), null, Entity.none]);
+ expect(values).toEqual([-9.8, -Infinity, new Map(), null, Entity.none]);
await moon.run(read);
- expect(values[0]).toBe(-1.6);
+ expect(values?.[0]).toBe(-1.6);
});
diff --git a/packages/ecs/src/guide/02-entities.test.ts b/packages/ecs/src/guide/02-entities.test.ts
index 55148ebd3..6ec401ced 100644
--- a/packages/ecs/src/guide/02-entities.test.ts
+++ b/packages/ecs/src/guide/02-entities.test.ts
@@ -1,11 +1,11 @@
// © 2026 Adobe. MIT License. See /LICENSE for details.
import { expect, it } from "vitest";
-import { Archetype, Component, System, Vec3, World } from "../index.js";
+import { System, Vec3, World } from "../index.js";
-// A component is a key whose values entities hold, declared and registered as a resource is.
-export const position = Symbol("position");
-export const velocity = Symbol("velocity");
-export const enemy = Symbol("enemy");
+// A component is a key whose values entities hold, declared as a resource is.
+export const position = Symbol();
+export const velocity = Symbol();
+export const enemy = Symbol();
declare module "@adobe/ecs" {
interface Components {
@@ -16,12 +16,9 @@ declare module "@adobe/ecs" {
}
}
-Component.register(position, { type: Vec3.schema });
-Component.register(velocity, { type: Vec3.schema });
-Component.register(enemy, { type: { const: true } });
-
-// An archetype names a list of components, once at module scope; it goes wherever a list of components does.
-export const Particle = Archetype.create([position, velocity]);
+// An archetype is a list of components declared `as const` at module scope, which the build indexes; it goes wherever
+// a list of components does.
+export const Particle = [position, velocity] as const;
// Systems spawn entities: name the components, then give their values.
export const spawn = System.create(({ commands }) => {
@@ -30,17 +27,17 @@ export const spawn = System.create(({ commands }) => {
});
// Commands also add and remove components, and despawn entities.
-export const defeat = System.create({ components: { has: [enemy] } }, (row, { commands }) => {
+export const defeat = System.create({ components: { with: [enemy] } }, (row, { commands }) => {
commands.remove(row.entity, enemy);
});
// An observer system reads them back, once per entity with its components.
const foes: Vec3[] = [];
-const readFoes = System.create({ observer: true, components: { read: [position], has: [enemy] } }, row => {
+const readFoes = System.create({ observer: true, components: { with: [position, enemy] } }, row => {
foes.push(row[position]);
});
let particles = 0;
-const countParticles = System.create({ observer: true, components: { has: Particle } }, () => {
+const countParticles = System.create({ observer: true, components: { with: [...Particle] } }, () => {
particles += 1;
});
diff --git a/packages/ecs/src/guide/03-systems.test.ts b/packages/ecs/src/guide/03-systems.test.ts
index dbc7e81b0..7cc760020 100644
--- a/packages/ecs/src/guide/03-systems.test.ts
+++ b/packages/ecs/src/guide/03-systems.test.ts
@@ -1,12 +1,12 @@
// © 2026 Adobe. MIT License. See /LICENSE for details.
import { expect, it } from "vitest";
-import { Component, Report, Resource, System, Vec3, World } from "../index.js";
+import { Report, System, Vec3, World } from "../index.js";
-const position = Symbol("position");
-const velocity = Symbol("velocity");
-const frozen = Symbol("frozen");
-const delta = Symbol("delta");
-const frames = Symbol("frames");
+const position = Symbol();
+const velocity = Symbol();
+const frozen = Symbol();
+const delta = Symbol();
+const frames = Symbol();
declare module "@adobe/ecs" {
interface Components {
@@ -15,35 +15,27 @@ declare module "@adobe/ecs" {
[frozen]: { type: true };
}
interface Resources {
- [delta]: { type: number };
+ [delta]: { type: number; default: 0.5 };
[frames]: { type: number };
}
}
-Component.register(position, { type: Vec3.schema });
-Component.register(velocity, { type: Vec3.schema });
-Component.register(frozen, { type: { const: true } });
-Resource.register(delta, { type: { type: "number", default: 0.5 } });
-Resource.register(frames, { type: { type: "number", default: 0 } });
-
const spawn = System.create(({ commands }) => {
commands.spawn([position, velocity])({ [position]: [0, 0, 0], [velocity]: [2, 0, 0] });
commands.spawn([position, velocity, frozen])({ [position]: [0, 0, 0], [velocity]: [2, 0, 0] });
});
-// Runs once per entity with every component in read and write, and none in not.
+// Runs once per entity with every component in with, and none in not. What its body reads and writes the build
+// infers.
const move = System.create({
- // Optional: labels the system in reports.
- name: "physics/move",
- components: { read: [velocity], write: [position], not: [frozen] },
- resources: { read: [delta] },
+ components: { with: [velocity, position], not: [frozen] },
}, (row, { resources }) => {
// Values are replaced whole, never edited in place.
row[position] = Vec3.add(row[position], Vec3.scale(row[velocity], resources[delta]));
});
// Without components, a system runs once per run.
-const count = System.create({ resources: { write: [frames] } }, ({ resources }) => {
+const count = System.create({}, ({ resources }) => {
resources[frames] += 1;
});
@@ -51,7 +43,7 @@ const count = System.create({ resources: { write: [frames] } }, ({ resources })
// react to it.
const moved: Vec3[] = [];
let counted = 0;
-const read = System.create({ observer: true, components: { read: [position], not: [frozen] }, resources: { read: [frames] } }, (row, { resources }) => {
+const read = System.create({ observer: true, components: { with: [position], not: [frozen] } }, (row, { resources }) => {
moved.push(row[position]);
counted = resources[frames];
});
@@ -67,7 +59,26 @@ it("runs a system once per entity, or once", async () => {
expect(counted).toBe(1);
});
-const fail = System.create({ name: "fail" }, () => {
+const spawnStill = System.create(({ commands }) => {
+ commands.spawn([position])({ [position]: [5, 0, 0] });
+ commands.spawn([position, velocity])({ [position]: [0, 0, 0], [velocity]: [2, 0, 0] });
+});
+
+// An optional component is in a row where its entity has it, and undefined where it doesn't: it never narrows which
+// entities a system runs on. Commands add and remove it; assigning undefined removes nothing.
+const speeds: (Vec3 | undefined)[] = [];
+const readSpeeds = System.create({ observer: true, components: { with: [position], optional: [velocity] } }, row => {
+ speeds.push(row[velocity]);
+});
+
+it("reads optional components where entities have them", async () => {
+ await using world = World.create();
+ await world.run(spawnStill);
+ await world.run(readSpeeds);
+ expect(speeds).toEqual([undefined, [2, 0, 0]]);
+});
+
+const fail = System.create(() => {
throw new Error("broken");
});
@@ -75,7 +86,7 @@ const fail = System.create({ name: "fail" }, () => {
// it took, and `Report.errors` is there only when something went wrong. An observer system added to follow errors as
// they change logs them; what observer systems throw is in the next run's report.
const logged: string[] = [];
-const logFailures = System.create({ observer: true, components: { read: [Report.errors], changed: [Report.errors] } }, row => {
+const logFailures = System.create({ observer: true, components: { with: [Report.errors], changed: [Report.errors] } }, row => {
for (const failure of row[Report.errors].failures) logged.push(`${failure.name}: ${failure.message}`);
});
diff --git a/packages/ecs/src/guide/04-ticks.test.ts b/packages/ecs/src/guide/04-ticks.test.ts
index 21e84f4fe..286b2832a 100644
--- a/packages/ecs/src/guide/04-ticks.test.ts
+++ b/packages/ecs/src/guide/04-ticks.test.ts
@@ -1,9 +1,9 @@
// © 2026 Adobe. MIT License. See /LICENSE for details.
import { expect, it } from "vitest";
-import { always, cadence, clock, Resource, System, wake, World } from "../index.js";
+import { always, cadence, clock, System, wake, World } from "../index.js";
-const elapsed = Symbol("elapsed");
-const steps = Symbol("steps");
+const elapsed = Symbol();
+const steps = Symbol();
declare module "@adobe/ecs" {
interface Resources {
@@ -12,22 +12,19 @@ declare module "@adobe/ecs" {
}
}
-Resource.register(elapsed, { type: { type: "number", default: 0 } });
-Resource.register(steps, { type: { type: "number", default: 0 } });
-
// clock is the current tick's: delta is the milliseconds since the last one.
-const advance = System.create({ resources: { read: [clock], write: [elapsed] } }, ({ resources }) => {
+const advance = System.create({}, ({ resources }) => {
resources[elapsed] += resources[clock].delta;
});
// Added, an observer system reacts to changes: with elapsed in changed, it runs after each tick that changed it.
let reached: (value: number) => void = () => {};
-const watch = System.create({ observer: true, resources: { read: [elapsed], changed: [elapsed] } }, ({ resources }) => {
+const watch = System.create({ observer: true, resources: { changed: [elapsed] } }, ({ resources }) => {
if (resources[elapsed] >= 30) reached(resources[elapsed]);
});
let read = { elapsed: 0, delta: 0 };
-const readTime = System.create({ observer: true, resources: { read: [elapsed, clock] } }, ({ resources }) => {
+const readTime = System.create({ observer: true }, ({ resources }) => {
read = { elapsed: resources[elapsed], delta: resources[clock].delta };
});
@@ -53,13 +50,13 @@ it("runs added systems on every tick", async () => {
// A system asks for another tick by contributing a time to wake: the first tick at or after it runs. Work that a tick's
// commands start and a later tick finishes asks for the next one this way.
-const step = System.create({ resources: { read: [clock], write: [steps] }, reduce: { [wake]: "min" } }, ({ resources, reduce }) => {
+const step = System.create({}, ({ resources, reduce }) => {
resources[steps] += 1;
if (resources[steps] < 3) reduce[wake](resources[clock].now);
});
let finished: (value: number) => void = () => {};
-const watchSteps = System.create({ observer: true, resources: { read: [steps], changed: [steps] } }, ({ resources }) => {
+const watchSteps = System.create({ observer: true, resources: { changed: [steps] } }, ({ resources }) => {
if (resources[steps] === 3) finished(resources[steps]);
});
diff --git a/packages/ecs/src/guide/05-scheduling.test.ts b/packages/ecs/src/guide/05-scheduling.test.ts
index e91f21034..c562880da 100644
--- a/packages/ecs/src/guide/05-scheduling.test.ts
+++ b/packages/ecs/src/guide/05-scheduling.test.ts
@@ -1,9 +1,9 @@
// © 2026 Adobe. MIT License. See /LICENSE for details.
import { expect, it } from "vitest";
-import { Component, System, World } from "../index.js";
+import { System, World } from "../index.js";
-const health = Symbol("health");
-const damage = Symbol("damage");
+const health = Symbol();
+const damage = Symbol();
declare module "@adobe/ecs" {
interface Components {
@@ -12,23 +12,20 @@ declare module "@adobe/ecs" {
}
}
-Component.register(health, { type: { type: "number" } });
-Component.register(damage, { type: { type: "number" } });
-
const spawn = System.create(({ commands }) => { commands.spawn([health, damage])({ [health]: 10, [damage]: 4 }); });
-const hit = System.create({ components: { read: [damage], write: [health] } }, row => {
+const hit = System.create({ components: { with: [damage, health] } }, row => {
row[health] -= row[damage];
});
// before, after and during list the systems themselves.
-const heal = System.create({ components: { write: [health] }, after: [hit] }, row => {
+const heal = System.create({ components: { with: [health] }, after: [hit] }, row => {
row[health] = Math.min(row[health] * 2, 10);
});
// A list of systems is a composite: it runs its members as one system, in their order; members without an order
-// between them may run in parallel. { systems, after, ... } declares one with a name or constraints.
+// between them may run in parallel. { systems, after, ... } declares one with constraints.
const frame = System.create([heal, hit]);
const healths: number[] = [];
-const read = System.create({ observer: true, components: { read: [health] } }, row => {
+const read = System.create({ observer: true, components: { with: [health] } }, row => {
healths.push(row[health]);
});
@@ -44,11 +41,11 @@ it("orders a composite's members by their constraints", async () => {
// Phases are systems that do nothing; a system during one runs after what the phase follows and before what follows it.
const update = System.create(() => {});
const render = System.create({ after: [update] }, () => {});
-const poison = System.create({ components: { write: [health] }, during: [update] }, row => {
+const poison = System.create({ components: { with: [health] }, during: [update] }, row => {
row[health] -= 1;
});
const seen: number[] = [];
-const report = System.create({ components: { read: [health] }, during: [render] }, row => {
+const report = System.create({ components: { with: [health] }, during: [render] }, row => {
seen.push(row[health]);
});
const phased = System.create([report, poison, update, render]);
diff --git a/packages/ecs/src/guide/06-sync-points.test.ts b/packages/ecs/src/guide/06-sync-points.test.ts
index e27a8156b..11df7cfd6 100644
--- a/packages/ecs/src/guide/06-sync-points.test.ts
+++ b/packages/ecs/src/guide/06-sync-points.test.ts
@@ -1,9 +1,9 @@
// © 2026 Adobe. MIT License. See /LICENSE for details.
import { expect, it } from "vitest";
-import { Component, Resource, System, World } from "../index.js";
+import { System, World } from "../index.js";
-const health = Symbol("health");
-const seen = Symbol("seen");
+const health = Symbol();
+const seen = Symbol();
declare module "@adobe/ecs" {
interface Components {
@@ -14,9 +14,6 @@ declare module "@adobe/ecs" {
}
}
-Component.register(health, { type: { type: "number" } });
-Resource.register(seen, { type: { type: "number", default: 0 } });
-
// Value writes apply at once; commands wait for the sync point that ends each run, then apply in the order issued.
const spawn = System.create(({ commands }) => {
// spawn gives the entity at once, so later commands can name it.
@@ -26,18 +23,18 @@ const spawn = System.create(({ commands }) => {
commands.despawn(commands.spawn([health])({ [health]: 1 }));
});
// A composite is one run with one sync point: count runs after spawn, before its entities exist.
-const count = System.create({ components: { has: [health] }, resources: { write: [seen] }, after: [spawn] }, (row, { resources }) => {
+const count = System.create({ components: { with: [health] }, after: [spawn] }, (row, { resources }) => {
resources[seen] += 1;
});
const frame = System.create([spawn, count]);
const healths: number[] = [];
// An observer runs after the sync point of every run and tick it's added to, reading what it left; it writes nothing.
-const watch = System.create({ observer: true, components: { read: [health] } }, row => {
+const watch = System.create({ observer: true, components: { with: [health] } }, row => {
healths.push(row[health]);
});
let counted = 0;
-const readSeen = System.create({ observer: true, resources: { read: [seen] } }, ({ resources }) => {
+const readSeen = System.create({ observer: true }, ({ resources }) => {
counted = resources[seen];
});
diff --git a/packages/ecs/src/guide/07-change-filters.test.ts b/packages/ecs/src/guide/07-change-filters.test.ts
index 457ba502b..1af9c016b 100644
--- a/packages/ecs/src/guide/07-change-filters.test.ts
+++ b/packages/ecs/src/guide/07-change-filters.test.ts
@@ -1,14 +1,14 @@
// © 2026 Adobe. MIT License. See /LICENSE for details.
import { expect, it } from "vitest";
-import { Component, Resource, System, World } from "../index.js";
+import { System, World } from "../index.js";
-const score = Symbol("score");
-const shown = Symbol("shown");
-const boss = Symbol("boss");
-const level = Symbol("level");
-const paused = Symbol("paused");
-const map = Symbol("map");
-const steps = Symbol("steps");
+const score = Symbol();
+const shown = Symbol();
+const boss = Symbol();
+const level = Symbol();
+const paused = Symbol();
+const map = Symbol();
+const steps = Symbol();
declare module "@adobe/ecs" {
interface Components {
@@ -17,36 +17,28 @@ declare module "@adobe/ecs" {
[boss]: { type: true };
}
interface Resources {
- [level]: { type: number };
+ [level]: { type: number; default: 1 };
[paused]: { type: boolean };
[map]: { type: { readonly width: number } | null };
[steps]: { type: number };
}
}
-Component.register(score, { type: { type: "number" } });
-Component.register(shown, { type: { type: "number" } });
-Component.register(boss, { type: { const: true } });
-Resource.register(level, { type: { type: "number", default: 1 } });
-Resource.register(paused, { type: { type: "boolean", default: false } });
-Resource.register(map, { type: { anyOf: [{ type: "object", properties: { width: { type: "number" } } }, { type: "null" }] } });
-Resource.register(steps, { type: { type: "number", default: 0 } });
-
// A resource in changed passes the first run and each run after it changes: no run-once flag needed.
-const load = System.create({ resources: { read: [level], changed: [level] } }, ({ resources, commands }) => {
+const load = System.create({ resources: { changed: [level] } }, ({ resources, commands }) => {
commands.spawn([score, shown, boss])({ [score]: 0, [shown]: -1 });
for (let i = 0; i < resources[level]; i++) commands.spawn([score, shown])({ [score]: 0, [shown]: -1 });
});
// A component in changed passes only rows written since this system last ran.
-const show = System.create({ components: { read: [score], write: [shown], changed: [score] } }, row => {
+const show = System.create({ components: { with: [score, shown], changed: [score] } }, row => {
row[shown] = row[score];
});
-const bump = System.create({ components: { write: [score], has: [boss] } }, row => {
+const bump = System.create({ components: { with: [score, boss] } }, row => {
row[score] += 10;
});
const shownValues: number[] = [];
-const readShown = System.create({ observer: true, components: { read: [shown] } }, row => {
+const readShown = System.create({ observer: true, components: { with: [shown] } }, row => {
shownValues.push(row[shown]);
});
@@ -65,14 +57,14 @@ it("runs systems only for what changed since they last ran", async () => {
});
// Runs only while each resource in is is neither null nor false, and each in not is; is reads it narrowed.
-const step = System.create({ resources: { is: [map], not: [paused], write: [steps] } }, ({ resources }) => {
+const step = System.create({ resources: { is: [map], not: [paused] } }, ({ resources }) => {
resources[steps] += resources[map].width;
});
-const pause = System.create({ resources: { write: [paused] } }, ({ resources }) => { resources[paused] = true; });
-const open = System.create({ resources: { write: [map] } }, ({ resources }) => { resources[map] = { width: 4 }; });
+const pause = System.create({}, ({ resources }) => { resources[paused] = true; });
+const open = System.create({}, ({ resources }) => { resources[map] = { width: 4 }; });
let stepped = 0;
-const readSteps = System.create({ observer: true, resources: { read: [steps] } }, ({ resources }) => {
+const readSteps = System.create({ observer: true }, ({ resources }) => {
stepped = resources[steps];
});
diff --git a/packages/ecs/src/guide/08-input.test.ts b/packages/ecs/src/guide/08-input.test.ts
index 7b78cf131..511b4624d 100644
--- a/packages/ecs/src/guide/08-input.test.ts
+++ b/packages/ecs/src/guide/08-input.test.ts
@@ -1,8 +1,8 @@
// © 2026 Adobe. MIT License. See /LICENSE for details.
import { expect, it } from "vitest";
-import { Component, Entity, System, World } from "../index.js";
+import { Entity, System, World } from "../index.js";
-const health = Symbol("health");
+const health = Symbol();
declare module "@adobe/ecs" {
interface Components {
@@ -10,30 +10,26 @@ declare module "@adobe/ecs" {
}
}
-Component.register(health, { type: { type: "number" } });
-
let player = Entity.none;
const spawn = System.create(({ commands }) => { player = commands.spawn([health])({ [health]: 10 }); });
-// input is an object schema of named fields; each run passes a value of it.
+// A body's input is its parameter after its context, an object of named fields; each run passes a value of it.
const hit = System.create({
- components: { write: [health] },
- input: { type: "object", properties: { amount: { type: "number" } } },
-}, (row, { input }) => {
+ components: { with: [health] },
+}, (row, _context, input: { readonly amount: number }) => {
row[health] -= input.amount;
});
// target names an input field holding an entity: the system runs on that entity's row alone, or on none once it's gone.
const heal = System.create({
- components: { write: [health] },
- input: { type: "object", properties: { entity: Entity.schema, amount: { type: "number" } } },
+ components: { with: [health] },
target: "entity",
-}, (row, { input }) => {
+}, (row, _context, input: { readonly entity: Entity; readonly amount: number }) => {
row[health] += input.amount;
});
const healths: number[] = [];
-const read = System.create({ observer: true, components: { read: [health] } }, row => {
+const read = System.create({ observer: true, components: { with: [health] } }, row => {
healths.push(row[health]);
});
diff --git a/packages/ecs/src/guide/09-rules.test.ts b/packages/ecs/src/guide/09-rules.test.ts
index 1f4d0ed28..8ac1914ca 100644
--- a/packages/ecs/src/guide/09-rules.test.ts
+++ b/packages/ecs/src/guide/09-rules.test.ts
@@ -1,48 +1,42 @@
// © 2026 Adobe. MIT License. See /LICENSE for details.
import { expect, it } from "vitest";
-import { Component, Rule, System, World } from "../index.js";
+import { Rule, System, World } from "../index.js";
-const mesh = Symbol("mesh");
-const sprite = Symbol("sprite");
-const hidden = Symbol("hidden");
-const drawn = Symbol("drawn");
-const layer = Symbol("layer");
+const mesh = Symbol();
+const sprite = Symbol();
+const hidden = Symbol();
+const drawn = Symbol();
+const layer = Symbol();
declare module "@adobe/ecs" {
interface Components {
[mesh]: { type: number };
[sprite]: { type: number };
[hidden]: { type: true };
- // Only rules add derived components, starting at their schema's default.
+ // Only rules add derived components, starting at their default.
[drawn]: { type: true; derived: true };
- [layer]: { type: number; derived: true };
+ [layer]: { type: number; derived: true; default: 1 };
}
}
-Component.register(mesh, { type: { type: "number" } });
-Component.register(sprite, { type: { type: "number" } });
-Component.register(hidden, { type: { const: true } });
-Component.register(drawn, { type: { const: true }, derived: true });
-Component.register(layer, { type: { type: "number", default: 1 }, derived: true });
-
// Entities matching a rule have what it derives, only while they match.
-Rule.create({ components: { has: [mesh], not: [hidden] }, derive: [drawn] });
+Rule.create({ components: { with: [mesh], not: [hidden] }, derive: [drawn] });
// Several rules may derive one component, present while any matches.
-Rule.create({ components: { has: [sprite], not: [hidden] }, derive: [drawn] });
+Rule.create({ components: { with: [sprite], not: [hidden] }, derive: [drawn] });
// Rules build on what others derive.
-Rule.create({ components: { has: [drawn] }, derive: [layer] });
+Rule.create({ components: { with: [drawn] }, derive: [layer] });
const spawn = System.create(({ commands }) => {
commands.spawn([mesh])({ [mesh]: 1 });
commands.spawn([sprite])({ [sprite]: 2 });
commands.spawn([mesh, hidden])({ [mesh]: 3 });
});
-const hide = System.create({ components: { has: [sprite] } }, (row, { commands }) => {
+const hide = System.create({ components: { with: [sprite] } }, (row, { commands }) => {
commands.add(row.entity, hidden);
});
const layers: number[] = [];
-const read = System.create({ observer: true, components: { read: [layer], has: [drawn] } }, row => {
+const read = System.create({ observer: true, components: { with: [layer, drawn] } }, row => {
layers.push(row[layer]);
});
diff --git a/packages/ecs/src/guide/10-finalizers.test.ts b/packages/ecs/src/guide/10-finalizers.test.ts
index 7aae82c35..a4ff4af0c 100644
--- a/packages/ecs/src/guide/10-finalizers.test.ts
+++ b/packages/ecs/src/guide/10-finalizers.test.ts
@@ -1,8 +1,8 @@
// © 2026 Adobe. MIT License. See /LICENSE for details.
import { expect, it } from "vitest";
-import { Component, Finalizer, System, World } from "../index.js";
+import { Finalizer, System, World } from "../index.js";
-const handle = Symbol("handle");
+const handle = Symbol();
declare module "@adobe/ecs" {
interface Components {
@@ -10,13 +10,11 @@ declare module "@adobe/ecs" {
}
}
-Component.register(handle, { type: { type: "number" } });
-
/** What handles name outside the world, as GPU buffers and DOM nodes are. */
const open = new Set();
// Runs for each entity that stops matching, whatever the cause, reading its values from before.
-Finalizer.create({ components: { read: [handle] } }, row => {
+Finalizer.create({ components: { with: [handle] } }, row => {
open.delete(row[handle]);
});
@@ -26,7 +24,7 @@ const spawn = System.create(({ commands }) => {
commands.spawn([handle])({ [handle]: id });
}
});
-const destroy = System.create({ components: { read: [handle] } }, (row, { commands }) => {
+const destroy = System.create({ components: { with: [handle] } }, (row, { commands }) => {
if (row[handle] === 7) commands.despawn(row.entity);
else commands.remove(row.entity, handle);
});
diff --git a/packages/ecs/src/guide/11-traits.test.ts b/packages/ecs/src/guide/11-traits.test.ts
index ac41a53f7..0bb7f919a 100644
--- a/packages/ecs/src/guide/11-traits.test.ts
+++ b/packages/ecs/src/guide/11-traits.test.ts
@@ -1,14 +1,14 @@
// © 2026 Adobe. MIT License. See /LICENSE for details.
import { expect, it } from "vitest";
-import { Component, Entity, System, World } from "../index.js";
+import { Entity, System, World } from "../index.js";
-const ship = Symbol("ship");
-const parent = Symbol("parent");
-const target = Symbol("target");
-const selected = Symbol("selected");
-const outline = Symbol("outline");
+const ship = Symbol();
+const parent = Symbol();
+const target = Symbol();
+const selected = Symbol();
+const outline = Symbol();
-// Traits are the fields of a key's declaration; type is one. Registration repeats them.
+// Traits are the fields of a key's declaration; type is one.
declare module "@adobe/ecs" {
interface Components {
[ship]: { type: true };
@@ -22,27 +22,21 @@ declare module "@adobe/ecs" {
}
}
-Component.register(ship, { type: { const: true } });
-Component.register(parent, { type: Entity.schema, reference: "owner" });
-Component.register(target, { type: Entity.schema, reference: "weak" });
-Component.register(selected, { type: { const: true } });
-Component.register(outline, { type: { type: "number" }, within: selected });
-
const spawn = System.create(({ commands }) => {
const mothership = commands.spawn([ship, selected, outline])({ [outline]: 2 });
commands.spawn([parent])({ [parent]: mothership });
commands.spawn([target])({ [target]: mothership });
});
-const deselect = System.create({ components: { has: [selected] } }, (row, { commands }) => {
+const deselect = System.create({ components: { with: [selected] } }, (row, { commands }) => {
commands.remove(row.entity, selected);
});
-const destroy = System.create({ components: { has: [ship] } }, (row, { commands }) => {
+const destroy = System.create({ components: { with: [ship] } }, (row, { commands }) => {
commands.despawn(row.entity);
});
const counts = { outlined: 0, targeting: 0, all: 0 };
-const countOutlined = System.create({ observer: true, components: { has: [outline] } }, () => { counts.outlined += 1; });
-const countTargeting = System.create({ observer: true, components: { has: [target] } }, () => { counts.targeting += 1; });
+const countOutlined = System.create({ observer: true, components: { with: [outline] } }, () => { counts.outlined += 1; });
+const countTargeting = System.create({ observer: true, components: { with: [target] } }, () => { counts.targeting += 1; });
const countAll = System.create({ observer: true, components: {} }, () => { counts.all += 1; });
it("removes components with their host, and follows references", async () => {
diff --git a/packages/ecs/src/guide/11-traits.type-test.ts b/packages/ecs/src/guide/11-traits.type-test.ts
index 609ef53dc..9a70b23bb 100644
--- a/packages/ecs/src/guide/11-traits.type-test.ts
+++ b/packages/ecs/src/guide/11-traits.type-test.ts
@@ -1,28 +1,25 @@
// © 2026 Adobe. MIT License. See /LICENSE for details.
-import { Component, Trait } from "../index.js";
+import { Component } from "../index.js";
type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false;
const check = <_T extends true>() => { };
// A trait of your own is a member of Traits: the value it allows on key N, or never where it doesn't apply.
-export const instanced = Symbol("instanced");
+export const instanced = Symbol();
declare module "@adobe/ecs" {
interface Traits {
readonly [instanced]: N extends Component.Key ? ([Component.Value] extends [readonly number[]] ? true : never) : never;
}
}
-Trait.register(instanced);
-const transform = Symbol("transform");
-const name = Symbol("name");
+const transform = Symbol();
+const name = Symbol();
declare module "@adobe/ecs" {
interface Components {
[transform]: { type: readonly [number, number, number]; [instanced]: true };
[name]: { type: string };
}
}
-Component.register(transform, { type: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 3 }, [instanced]: true });
-Component.register(name, { type: { type: "string" } });
// Types select components by trait.
check, typeof transform | typeof name>, typeof transform>>();
diff --git a/packages/ecs/src/guide/12-lookups.test.ts b/packages/ecs/src/guide/12-lookups.test.ts
new file mode 100644
index 000000000..79b146f98
--- /dev/null
+++ b/packages/ecs/src/guide/12-lookups.test.ts
@@ -0,0 +1,62 @@
+// © 2026 Adobe. MIT License. See /LICENSE for details.
+import { expect, it } from "vitest";
+import { Entity, System, World } from "../index.js";
+
+const health = Symbol();
+const target = Symbol();
+const frozen = Symbol();
+const aim = Symbol();
+
+declare module "@adobe/ecs" {
+ interface Components {
+ [health]: { type: number };
+ [target]: { type: Entity; reference: "weak" };
+ [frozen]: { type: true };
+ [aim]: { type: number };
+ }
+}
+
+// The entity's row, or null when it doesn't have every component in with, or has one in not. The row holds only what
+// with lists.
+const track = System.create({
+ components: { with: [target, aim] },
+ lookup: { enemy: { with: [health], not: [frozen] } },
+}, (row, { lookup }) => {
+ const enemy = lookup.enemy(row[target]);
+ row[aim] = enemy === null ? 0 : enemy[health];
+});
+
+// With changed, its row says whether those components may have changed since the system last ran.
+const refreshed: boolean[] = [];
+const watch = System.create({
+ observer: true,
+ components: { with: [target] },
+ lookup: { enemy: { with: [health], changed: [health] } },
+}, (row, { lookup }) => {
+ const enemy = lookup.enemy(row[target]);
+ if (enemy !== null) refreshed.push(enemy.changed);
+});
+
+const aims: number[] = [];
+const readAims = System.create({ observer: true, components: { with: [aim] } }, row => {
+ aims.push(row[aim]);
+});
+
+const spawn = System.create(({ commands }) => {
+ const ogre = commands.spawn([health])({ [health]: 40 });
+ const statue = commands.spawn([health, frozen])({ [health]: 90 });
+ commands.spawn([target, aim])({ [target]: ogre, [aim]: -1 });
+ commands.spawn([target, aim])({ [target]: statue, [aim]: -1 });
+});
+
+it("reads other entities by id", async () => {
+ await using world = World.create();
+ await world.run(spawn);
+ await world.run(track);
+ await world.run(readAims);
+ // The frozen statue doesn't match, so its tracker reads null.
+ expect(aims.sort()).toEqual([0, 40]);
+ await world.run(watch);
+ await world.run(watch);
+ expect(refreshed).toEqual([true, true, false, false]);
+});
diff --git a/packages/ecs/src/guide/13-indexes.test.ts b/packages/ecs/src/guide/13-indexes.test.ts
index dbc539f14..adaac90d2 100644
--- a/packages/ecs/src/guide/13-indexes.test.ts
+++ b/packages/ecs/src/guide/13-indexes.test.ts
@@ -1,70 +1,64 @@
// © 2026 Adobe. MIT License. See /LICENSE for details.
import { expect, it } from "vitest";
-import { Component, Index, System, World } from "../index.js";
+import { type I32, Index, System, type Vec2, World } from "../index.js";
-const cell = Symbol("cell");
-const crowded = Symbol("crowded");
-const score = Symbol("score");
-const player = Symbol("player");
-const serverId = Symbol("serverId");
+const cell = Symbol();
+const crowded = Symbol();
+const score = Symbol();
+const player = Symbol();
+const serverId = Symbol();
declare module "@adobe/ecs" {
interface Components {
- [cell]: { type: readonly [number, number] };
+ [cell]: { type: Vec2.I32 };
[crowded]: { type: number };
[score]: { type: number };
[player]: { type: string };
- [serverId]: { type: number };
+ [serverId]: { type: I32 };
}
}
-Component.register(cell, { type: { type: "array", items: { type: "integer" }, minItems: 2, maxItems: 2 } });
-Component.register(crowded, { type: { type: "number" } });
-Component.register(score, { type: { type: "number" } });
-Component.register(player, { type: { type: "string" } });
-Component.register(serverId, { type: { type: "integer" } });
-
-// An index finds entities by their components' values: here, those with a crowded count, by their cell.
-const grid = Index.create({ key: [cell], components: { has: [crowded] } });
+// Here, entities with a crowded count, by their cell.
+const grid = Index.create({ key: [cell], components: { with: [crowded] } });
// A lookup through it gives the rows with a key, in entity order.
-const count = System.create({ components: { read: [cell], write: [crowded] }, lookup: { at: { index: grid } } }, (row, { lookup }) => {
+const count = System.create({ components: { with: [cell, crowded] }, lookup: { at: { index: grid } } }, (row, { lookup }) => {
const [x, y] = row[cell];
row[crowded] = lookup.at([x, y]).length;
});
// A sorted index keeps its key's order: lookups take a range, and `order` runs a system's rows in it.
-const byScore = Index.create({ key: [score], sorted: true, components: { has: [player] } });
+const byScore = Index.create({ key: [score], sorted: true, components: { with: [player] } });
const winners: string[] = [];
-const leaders = System.create({ observer: true, lookup: { scores: { index: byScore, read: [player] } } }, ({ lookup }) => {
+const leaders = System.create({ observer: true, lookup: { scores: { index: byScore } } }, ({ lookup }) => {
winners.length = 0;
const high = lookup.scores({ ">=": 90 });
for (let i = 0; i < high.length; i++) winners.push(high.at(i)[player]);
});
const board: string[] = [];
-const list = System.create({ observer: true, components: { read: [player] }, order: byScore }, row => {
+const list = System.create({ observer: true, components: { with: [player] }, order: byScore }, row => {
board.push(row[player]);
});
// A unique index gives one row, or null.
const byServerId = Index.create({ key: [serverId], unique: true });
let found: number | null = null;
-const find = System.create({ input: { type: "object", properties: { id: { type: "integer" } } }, lookup: { byId: { index: byServerId } } }, ({ input, lookup }) => {
+const find = System.create({ lookup: { byId: { index: byServerId } } }, ({ lookup }, input: { readonly id: number }) => {
found = lookup.byId(input.id)?.entity ?? null;
});
// An index without a key holds every entity with its components, in entity order. With changed: true, this observer
// system runs only when an entity entered or left the index, so added, it keeps a count as players come and go.
-const players = Index.create({ key: [], components: { has: [player] } });
+const players = Index.create({ key: [], components: { with: [player] } });
let playerCount = 0;
const countPlayers = System.create({ observer: true, lookup: { players: { index: players, changed: true } } }, ({ lookup }) => {
playerCount = lookup.players().length;
});
const crowds: number[] = [];
-const readCrowds = System.create({ observer: true, components: { read: [crowded] } }, row => {
+const readCrowds = System.create({ observer: true, components: { with: [crowded] } }, row => {
crowds.push(row[crowded]);
});
diff --git a/packages/ecs/src/guide/14-kernels.test.ts b/packages/ecs/src/guide/14-kernels.test.ts
new file mode 100644
index 000000000..03e6bcf11
--- /dev/null
+++ b/packages/ecs/src/guide/14-kernels.test.ts
@@ -0,0 +1,54 @@
+// © 2026 Adobe. MIT License. See /LICENSE for details.
+import { expect, it } from "vitest";
+import { type F32, System, Vec3, World } from "../index.js";
+
+const position = Symbol();
+const velocity = Symbol();
+const delta = Symbol();
+
+declare module "@adobe/ecs" {
+ interface Components {
+ [position]: { type: Vec3 };
+ [velocity]: { type: Vec3 };
+ }
+ interface Resources {
+ [delta]: { type: F32; default: 0.5 };
+ }
+}
+
+// A kernel is a body run once per row, each row on its own, so the world can split rows across workers and run them as
+// WASM. A body that reads and writes only structs, calls math and touches no module state is one: the build compiles
+// it, with results the same as the body as written.
+const move = System.create({ components: { with: [velocity, position] } }, (row, { resources }) => {
+ row[position] = Vec3.add(row[position], Vec3.scale(row[velocity], resources[delta]));
+});
+
+// kernel: true asserts it: the build fails, saying why, if the body can't be one. It also computes f32 values at f32,
+// which is faster on dense rows.
+const drift = System.create({ kernel: true, components: { with: [velocity, position] } }, row => {
+ row[position] = Vec3.add(row[position], row[velocity]);
+});
+
+// kernel: false runs the body as written, on its thread: to step through it in a debugger, say.
+const slow = System.create({ kernel: false, components: { with: [velocity] } }, row => {
+ row[velocity] = Vec3.scale(row[velocity], 0.5);
+});
+
+const positions: Vec3[] = [];
+const read = System.create({ observer: true, components: { with: [position] } }, row => {
+ positions.push(row[position]);
+});
+
+const spawn = System.create(({ commands }) => {
+ for (let i = 0; i < 3; i++) commands.spawn([position, velocity])({ [position]: [i, 0, 0], [velocity]: [2, 0, 0] });
+});
+
+it("runs bodies as kernels", async () => {
+ await using world = World.create();
+ await world.run(spawn);
+ await world.run(move);
+ await world.run(drift);
+ await world.run(slow);
+ await world.run(read);
+ expect(positions.map(([x]) => x).sort()).toEqual([3, 4, 5]);
+});
diff --git a/packages/ecs/src/guide/15-reductions.test.ts b/packages/ecs/src/guide/15-reductions.test.ts
new file mode 100644
index 000000000..68b0e2145
--- /dev/null
+++ b/packages/ecs/src/guide/15-reductions.test.ts
@@ -0,0 +1,46 @@
+// © 2026 Adobe. MIT License. See /LICENSE for details.
+import { expect, it } from "vitest";
+import { type F32, System, World } from "../index.js";
+
+const mass = Symbol();
+const total = Symbol();
+const heaviest = Symbol();
+const count = Symbol();
+
+declare module "@adobe/ecs" {
+ interface Components {
+ [mass]: { type: F32 };
+ }
+ // A reduced resource names how contributions combine: "+", "*", "min", "max", or bitwise "&", "|", "^".
+ interface Resources {
+ [total]: { type: number; reduce: "+" };
+ [heaviest]: { type: F32; reduce: "max" };
+ [count]: { type: number; reduce: "+" };
+ }
+}
+
+// Each row contributes; every run starts each resource it reduces at its operator's identity and leaves it holding the
+// fold of what every row gave, however the rows were split across workers.
+const weigh = System.create({ components: { with: [mass] } }, (row, { reduce }) => {
+ reduce[total](row[mass]);
+ reduce[heaviest](row[mass]);
+ reduce[count](1);
+});
+
+// Read the result after the run, as any resource.
+let summary = "";
+const report = System.create({ observer: true }, ({ resources }) => {
+ summary = `${resources[count]} weigh ${resources[total]}, at most ${resources[heaviest]}`;
+});
+
+const spawn = System.create(({ commands }) => {
+ for (const value of [2, 5, 3]) commands.spawn([mass])({ [mass]: value });
+});
+
+it("folds rows into a resource", async () => {
+ await using world = World.create();
+ await world.run(spawn);
+ await world.run(weigh);
+ await world.run(report);
+ expect(summary).toBe("3 weigh 10, at most 5");
+});
diff --git a/packages/ecs/src/guide/16-gpu.test.ts b/packages/ecs/src/guide/16-gpu.test.ts
new file mode 100644
index 000000000..b9128a341
--- /dev/null
+++ b/packages/ecs/src/guide/16-gpu.test.ts
@@ -0,0 +1,58 @@
+// © 2026 Adobe. MIT License. See /LICENSE for details.
+import { expect, it } from "vitest";
+import { type F32, Gpu, System, Vec2, World } from "../index.js";
+
+const position = Symbol();
+const velocity = Symbol();
+const size = Symbol();
+
+declare module "@adobe/ecs" {
+ interface Components {
+ [position]: { type: Vec2 };
+ [velocity]: { type: Vec2 };
+ [size]: { type: F32 };
+ }
+}
+
+// kernel: "gpu" runs the body as a compute shader over the GPU's copy of its components, in a world given a device
+// (World.create({ [gpuDevice]: device })), and as a kernel on the CPU in one without. Its rows hold 32-bit numbers
+// and vectors of them; it has no lookups, change filters or commands.
+const move = System.create({ kernel: "gpu", components: { with: [velocity, position] } }, row => {
+ row[position] = Vec2.add(row[position], row[velocity]);
+});
+
+// A render system records draws over the GPU copy of the components its accessors name, in a world with a device.
+// Gpu.wgsl(draw) gives its shaders a function per accessor, `fn dotPosition(row: u32) -> vec2`, and
+// Gpu.layout(device) the bind group layout its pipeline takes. Each table is a group of matching rows to draw.
+let pipeline: GPURenderPipeline | null = null;
+let target: GPUTextureView | null = null;
+const draw = System.render({ shader: { accessors: { dotPosition: position, dotSize: size } } }, ({ encoder, tables }) => {
+ if (pipeline === null || target === null) return;
+ const pass = encoder.beginRenderPass({ colorAttachments: [{ view: target, loadOp: "clear", storeOp: "store" }] });
+ pass.setPipeline(pipeline);
+ for (const table of tables) {
+ table.bind(pass);
+ pass.draw(6, table.count);
+ }
+ pass.end();
+});
+const shaderCode = Gpu.wgsl(draw);
+
+const positions: Vec2[] = [];
+const read = System.create({ observer: true, components: { with: [position] } }, row => {
+ positions.push(row[position]);
+});
+
+const spawn = System.create(({ commands }) => {
+ commands.spawn([position, velocity, size])({ [position]: [0, 0], [velocity]: [1, 2], [size]: 4 });
+});
+
+it("runs GPU kernels on the CPU without a device, and skips render systems", async () => {
+ await using world = World.create();
+ await world.run(spawn);
+ await world.run(move);
+ await world.run(draw);
+ await world.run(read);
+ expect(positions).toEqual([[1, 2]]);
+ expect(shaderCode).toContain("fn dotPosition(row: u32) -> vec2");
+});
diff --git a/packages/ecs/src/guide/17-hierarchies.test.ts b/packages/ecs/src/guide/17-hierarchies.test.ts
new file mode 100644
index 000000000..dfb134986
--- /dev/null
+++ b/packages/ecs/src/guide/17-hierarchies.test.ts
@@ -0,0 +1,43 @@
+// © 2026 Adobe. MIT License. See /LICENSE for details.
+import { expect, it } from "vitest";
+import { Entity, System, World } from "../index.js";
+
+const parent = Symbol();
+const offset = Symbol();
+const placed = Symbol();
+const label = Symbol();
+
+declare module "@adobe/ecs" {
+ interface Components {
+ // Despawned with its parent.
+ [parent]: { type: Entity; reference: "owner" };
+ [offset]: { type: number };
+ [placed]: { type: number };
+ [label]: { type: string };
+ }
+}
+
+// up runs rows parent first, through a reference, so each row reads its parent's values from earlier in the same run:
+// context.up holds what the body reads of the parent, or null for a row without one.
+const place = System.create({ components: { with: [offset, placed] }, up: { through: parent } }, (row, { up }) => {
+ row[placed] = (up === null ? 0 : up[placed]) + row[offset];
+});
+
+const where: string[] = [];
+const read = System.create({ observer: true, components: { with: [label, placed] } }, row => {
+ where.push(`${row[label]} at ${row[placed]}`);
+});
+
+const spawn = System.create(({ commands }) => {
+ const table = commands.spawn([label, offset, placed])({ [label]: "table", [offset]: 10, [placed]: 0 });
+ const tray = commands.spawn([label, offset, placed, parent])({ [label]: "tray", [offset]: 2, [placed]: 0, [parent]: table });
+ commands.spawn([label, offset, placed, parent])({ [label]: "cup", [offset]: 1, [placed]: 0, [parent]: tray });
+});
+
+it("runs rows parent first", async () => {
+ await using world = World.create();
+ await world.run(spawn);
+ await world.run(place);
+ await world.run(read);
+ expect(where.sort()).toEqual(["cup at 13", "table at 10", "tray at 12"]);
+});
diff --git a/packages/ecs/src/guide/18-identity.test.ts b/packages/ecs/src/guide/18-identity.test.ts
new file mode 100644
index 000000000..39b083a5a
--- /dev/null
+++ b/packages/ecs/src/guide/18-identity.test.ts
@@ -0,0 +1,24 @@
+// © 2026 Adobe. MIT License. See /LICENSE for details.
+import { expect, it } from "vitest";
+import { Report, System, World } from "../index.js";
+
+// A key, system, index, rule or finalizer is the value its constructor returns: two written alike are two, and code
+// written apart can't collide. The build gives each one bound at a module's top level an id from where it's declared,
+// its package's name and version, its module's path and its binding, so other threads and a module evaluated again
+// by hot reload name the same one.
+const explode = System.create(() => {
+ throw new Error("boom");
+});
+
+// Reports label it by its binding, and give the id beside it to tell two of one label apart.
+const failures: string[] = [];
+const logFailures = System.create({ observer: true, components: { with: [Report.errors], changed: [Report.errors] } }, row => {
+ for (const failure of row[Report.errors].failures) failures.push(`${failure.name} ${failure.id}`);
+});
+
+it("labels systems by their binding and id", async () => {
+ await using world = World.create();
+ world.add(logFailures);
+ await world.run(explode);
+ expect(failures).toEqual([expect.stringMatching(/^explode @adobe\/ecs@[^/]+\/src\/guide\/18-identity\.test\.ts#explode$/)]);
+});
diff --git a/packages/ecs/src/guide/19-threads.test.ts b/packages/ecs/src/guide/19-threads.test.ts
new file mode 100644
index 000000000..015fed36c
--- /dev/null
+++ b/packages/ecs/src/guide/19-threads.test.ts
@@ -0,0 +1,55 @@
+// © 2026 Adobe. MIT License. See /LICENSE for details.
+import { expect, it } from "vitest";
+import { System, World } from "../index.js";
+
+const volume = Symbol();
+const levels = Symbol();
+
+// A logical thread is where objects live and the code touching them runs. Declare one, and place objects on it.
+declare module "@adobe/ecs" {
+ interface Threads {
+ audio: true;
+ }
+ interface Components {
+ [volume]: { type: number };
+ }
+ interface Resources {
+ [levels]: { type: ReadonlyMap; thread: "audio" };
+ }
+}
+
+// A system runs on the thread its objects live on: this one, writing levels, runs on audio.
+const mix = System.create({ components: { with: [volume] } }, (row, { resources }) => {
+ resources[levels] = new Map([...resources[levels], [`voice ${row.entity}`, row[volume]]]);
+});
+
+// Another thread joins a shared world, claiming the threads it fills, and adds observer systems that run there after
+// the world's sync points.
+const heard: number[] = [];
+const listen = System.create({ observer: true }, ({ resources }) => {
+ heard.push(resources[levels].size);
+});
+
+const spawn = System.create(({ commands }) => {
+ commands.spawn([volume])({ [volume]: 0.8 });
+ commands.spawn([volume])({ [volume]: 0.3 });
+});
+
+/** Waits a turn of the event loop at a time until `done`. */
+const until = async (done: () => boolean): Promise => {
+ while (!done()) await new Promise(resolve => setTimeout(resolve, 1));
+};
+
+it("shares a world with another thread", async () => {
+ // Usually one end of the channel goes to a worker, `new Worker(...)`, which joins with the other.
+ const channel = new MessageChannel();
+ await using world = World.create();
+ world.share(channel.port1);
+ await using audio = World.join(channel.port2, { threads: ["audio"] });
+ audio.add(listen);
+ await world.run(spawn);
+ // A joined world runs systems as the world does.
+ await audio.run(mix);
+ await until(() => heard.at(-1) === 2);
+ expect(heard.at(-1)).toBe(2);
+});
diff --git a/packages/ecs/src/guide/CLAUDE.md b/packages/ecs/src/guide/CLAUDE.md
index 66eb1607f..06342577c 100644
--- a/packages/ecs/src/guide/CLAUDE.md
+++ b/packages/ecs/src/guide/CLAUDE.md
@@ -13,22 +13,21 @@ code it shows: every excerpt comes from a file here that runs as a test, so the
compile-time checks prevent most mistakes.
- Never mention `@adobe/data` or any other package that isn't this one. It means nothing to a consumer.
- Describe only the current API, with no history.
-- Keep it small. Write sections for the basics and intermediate topics; advanced topics are placeholders until the
- API settles.
+- Keep it small. Advanced sections are as short as the rest: each shows the common case of its topic and stops.
## Structure
-- **Setup:** its own short section before the basics: the build plugin and how keys are registered and declared.
+- **Setup:** its own short section before the basics: the build plugin and how keys are declared.
- **Basics:** resources, then entities, components and archetypes, which together are the whole data model, then
systems, and ticks and cadence: everything needed to build and run a world and its loop. Topics that belong together
share a section; entities are taught with components, and archetypes, named lists of components, with them.
- **Intermediate:** scheduling and composites, sync points, change filters and conditions, input, rules, finalizers
and change sets, and traits with within and references.
-- **Advanced:** placeholders only.
+- **Advanced:** lookups, indexes, kernels, reductions, GPU kernels and render systems, hierarchies, identity and ids,
+ and threads.
## Code conventions
-- Keys are short: `Symbol("position")`, never a path like `"guide/components/position"`. A resource's description says
- nothing about components.
-- Archetypes are types, so their names are PascalCase: `const Particle = Archetype.create([position, velocity])`.
+- Keys are `Symbol()` with no description: the build's id names each by its binding.
+- Archetypes are types, so their names are PascalCase: `const Particle = [position, velocity] as const`.
- Use the public API only, as a consumer imports it, not internal modules. Read a world through observer systems.
diff --git a/packages/ecs/src/guide/guide.md b/packages/ecs/src/guide/guide.md
index 4554c0e38..7adf4b7de 100644
--- a/packages/ecs/src/guide/guide.md
+++ b/packages/ecs/src/guide/guide.md
@@ -35,16 +35,25 @@ How to use the ECS. Each excerpt comes from a test in this folder; the link unde
## Setup
```ts
-// vite.config.ts: compiles kernels, checks each system's declaration against its body, and gives keys and systems ids.
-import { kernelPlugin } from "@adobe/ecs/compiler";
-export default defineConfig({ plugins: [kernelPlugin()] });
+// vite.config.ts: infers what each system's body reads and writes, compiles kernels, reads the schema of each body's
+// input from its type, and gives keys and systems ids.
+import { Compiler } from "@adobe/ecs/compiler";
+export default defineConfig({ plugins: [Compiler.plugin()] });
```
-Each component and resource is declared once, by augmenting `Components` or `Resources` in `@adobe/ecs` with its
-traits, `type` among them, and registered once with the same traits. Registration's types check the two match.
-`type` is registered as a JSON Schema for the value, in which `{ type: "set", items }` and
-`{ type: "map", keys, values }` describe a `ReadonlySet` and a `ReadonlyMap`. Traits of your own augment `Traits`.
-The examples create worlds with `World.create()`.
+Each component and resource is declared once, in the module that makes its key, by augmenting `Components` or
+`Resources` in `@adobe/ecs` with its traits, `type` among them. The plugin registers each declaration: it reads the
+schema the value is stored by from `type`, so a type named by `Schema.Type`, such as `F32` or `U32`, gives its
+precision or width. Traits of your own augment `Traits`. While a declaration's trait or default doesn't hold, each
+`World.create` call fails to compile, naming the key, the member and what's wrong with it. A library builds with Vite and
+`Compiler.plugin({ library: true })`, so its build ships its registrations. Kernels run on workers as separate
+chunks, so the plugin sets Vite's `worker.format` to `"es"` when the config doesn't set it, for every worker the app
+bundles, its own included, which then load as module workers (`{ type: "module" }`). The examples create worlds with
+`World.create()`.
+
+A system declares only what its body can't show: the entities it runs over (`with` and `not`), its change filters
+and conditions, and its order. What its body reads, writes and reduces into the plugin infers from the body, so
+write the declaration and body where the system is made, and index rows and resources by key.
## Basics
@@ -54,50 +63,62 @@ Systems change that data, once per run or on every tick.
### 1. Resources
```ts
-export const gravity = Symbol("gravity");
-export const names = Symbol("names");
-export const audio = Symbol("audio");
-export const focused = Symbol("focused");
-
-// Its traits are declared; `type` is the value's type.
+export const gravity = Symbol();
+export const floor = Symbol();
+export const names = Symbol();
+export const audio = Symbol();
+export const focused = Symbol();
+
+// A type's namespace may give the value it starts at, which every row and world starting from it shares, so readonly.
+export type Height = F32;
+export const Height = { default: -Infinity } as const;
+
+// It's declared once, with its traits: `type` is the value's type, and `default` a literal it starts at. Without one,
+// it starts at its type's namespace's default, a reduction's identity, null, its zero value (0, false, "", item by
+// item), or an empty ReadonlyMap or ReadonlySet of each world's own. The build registers what's declared.
declare module "@adobe/ecs" {
interface Resources {
- [gravity]: { type: number };
+ [gravity]: { type: number; default: -9.8 };
+ [floor]: { type: Height };
[names]: { type: ReadonlyMap };
+ // A value that isn't data, such as a class, is stored as it is; a change inside it is seen once it's written back.
[audio]: { type: AudioContext | null };
+ // Entity.none, 0, rather than null, so an entity stays data every thread reads.
[focused]: { type: Entity };
}
}
-// Registration gives the same traits, with a schema that describes exactly the declared type. A resource always has a
-// value: its schema's default, or one beside it.
-Resource.register(gravity, { type: { type: "number", default: -9.8 } });
-Resource.register(names, { type: { type: "map", keys: { type: "string" }, values: { type: "number" } }, default: new Map() });
-// A value that isn't data has no schema and is stored as it is. A type that allows null starts there.
-Resource.register(audio, {});
-// No entity is Entity.none rather than null, so an entity stays data every thread reads.
-Resource.register(focused, { type: { ...Entity.schema, default: Entity.none } });
-
// Systems read a world: an observer only reads, after the world's changes apply.
-let values: readonly unknown[] = [];
-const read = System.create({ observer: true, resources: { read: [gravity, names, audio, focused] } }, ({ resources }) => {
- values = [resources[gravity], resources[names], resources[audio], resources[focused]];
-});
-
-await using moon = World.create({ [gravity]: -1.6 });
-// run: runs a system once.
-await moon.run(read);
-expect(values[0]).toBe(-1.6);
+let values: readonly [
+ Resource.Value,
+ Resource.Value,
+ Resource.Value,
+ Resource.Value,
+ Resource.Value
+] | null = null;
+const read = System.create({ observer: true }, ({ resources }) => {
+ values = [resources[gravity], resources[floor], resources[names], resources[audio], resources[focused]];
+});
+
+it("holds one value per world", async () => {
+ // A world starts each resource at its default, or at the value given when it's created.
+ await using earth = World.create();
+ await using moon = World.create({ [gravity]: -1.6 });
+ // run: runs a system once.
+ await earth.run(read);
+ expect(values).toEqual([-9.8, -Infinity, new Map(), null, Entity.none]);
+ await moon.run(read);
+ expect(values?.[0]).toBe(-1.6);
```
-[`01-resources.test.ts`](01-resources.test.ts#L6-L46)
+[`01-resources.test.ts`](01-resources.test.ts#L7-L52)
### 2. Entities, components and archetypes
```ts
-export const position = Symbol("position");
-export const velocity = Symbol("velocity");
-export const enemy = Symbol("enemy");
+export const position = Symbol();
+export const velocity = Symbol();
+export const enemy = Symbol();
declare module "@adobe/ecs" {
interface Components {
@@ -108,12 +129,9 @@ declare module "@adobe/ecs" {
}
}
-Component.register(position, { type: Vec3.schema });
-Component.register(velocity, { type: Vec3.schema });
-Component.register(enemy, { type: { const: true } });
-
-// An archetype names a list of components, once at module scope; it goes wherever a list of components does.
-export const Particle = Archetype.create([position, velocity]);
+// An archetype is a list of components declared `as const` at module scope, which the build indexes; it goes wherever
+// a list of components does.
+export const Particle = [position, velocity] as const;
// Systems spawn entities: name the components, then give their values.
export const spawn = System.create(({ commands }) => {
@@ -122,45 +140,45 @@ export const spawn = System.create(({ commands }) => {
});
// Commands also add and remove components, and despawn entities.
-export const defeat = System.create({ components: { has: [enemy] } }, (row, { commands }) => {
+export const defeat = System.create({ components: { with: [enemy] } }, (row, { commands }) => {
commands.remove(row.entity, enemy);
});
// An observer system reads them back, once per entity with its components.
const foes: Vec3[] = [];
-const readFoes = System.create({ observer: true, components: { read: [position], has: [enemy] } }, row => {
+const readFoes = System.create({ observer: true, components: { with: [position, enemy] } }, row => {
foes.push(row[position]);
});
let particles = 0;
-const countParticles = System.create({ observer: true, components: { has: Particle } }, () => {
+const countParticles = System.create({ observer: true, components: { with: [...Particle] } }, () => {
particles += 1;
});
-await world.run(spawn);
-await world.run(readFoes);
-await world.run(countParticles);
-expect(foes).toEqual([[0, 10, 0]]);
-expect(particles).toBe(1);
+it("spawns entities with components", async () => {
+ await using world = World.create();
+ await world.run(spawn);
+ await world.run(readFoes);
+ await world.run(countParticles);
+ expect(foes).toEqual([[0, 10, 0]]);
+ expect(particles).toBe(1);
```
-[`02-entities.test.ts`](02-entities.test.ts#L6-L59)
+[`02-entities.test.ts`](02-entities.test.ts#L6-L50)
### 3. Systems
```ts
-// Runs once per entity with every component in read and write, and none in not.
+// Runs once per entity with every component in with, and none in not. What its body reads and writes the build
+// infers.
const move = System.create({
- // Optional: labels the system in reports.
- name: "physics/move",
- components: { read: [velocity], write: [position], not: [frozen] },
- resources: { read: [delta] },
+ components: { with: [velocity, position], not: [frozen] },
}, (row, { resources }) => {
// Values are replaced whole, never edited in place.
row[position] = Vec3.add(row[position], Vec3.scale(row[velocity], resources[delta]));
});
// Without components, a system runs once per run.
-const count = System.create({ resources: { write: [frames] } }, ({ resources }) => {
+const count = System.create({}, ({ resources }) => {
resources[frames] += 1;
});
@@ -168,21 +186,43 @@ const count = System.create({ resources: { write: [frames] } }, ({ resources })
// react to it.
const moved: Vec3[] = [];
let counted = 0;
-const read = System.create({ observer: true, components: { read: [position], not: [frozen] }, resources: { read: [frames] } }, (row, { resources }) => {
+const read = System.create({ observer: true, components: { with: [position], not: [frozen] } }, (row, { resources }) => {
moved.push(row[position]);
counted = resources[frames];
});
-await world.run(move);
-await world.run(count);
-await world.run(read);
-expect(moved).toEqual([[1, 0, 0]]);
+it("runs a system once per entity, or once", async () => {
+ await using world = World.create();
+ // run: runs a system once.
+ await world.run(spawn);
+ await world.run(move);
+ await world.run(count);
+ await world.run(read);
+ expect(moved).toEqual([[1, 0, 0]]);
```
-[`03-systems.test.ts`](03-systems.test.ts#L35-L69)
+[`03-systems.test.ts`](03-systems.test.ts#L28-L58)
```ts
-const fail = System.create({ name: "fail" }, () => {
+// An optional component is in a row where its entity has it, and undefined where it doesn't: it never narrows which
+// entities a system runs on. Commands add and remove it; assigning undefined removes nothing.
+const speeds: (Vec3 | undefined)[] = [];
+const readSpeeds = System.create({ observer: true, components: { with: [position], optional: [velocity] } }, row => {
+ speeds.push(row[velocity]);
+});
+
+it("reads optional components where entities have them", async () => {
+ await using world = World.create();
+ await world.run(spawnStill);
+ await world.run(readSpeeds);
+ expect(speeds).toEqual([undefined, [2, 0, 0]]);
+});
+```
+
+[`03-systems.test.ts`](03-systems.test.ts#L67-L79)
+
+```ts
+const fail = System.create(() => {
throw new Error("broken");
});
@@ -190,95 +230,114 @@ const fail = System.create({ name: "fail" }, () => {
// it took, and `Report.errors` is there only when something went wrong. An observer system added to follow errors as
// they change logs them; what observer systems throw is in the next run's report.
const logged: string[] = [];
-const logFailures = System.create({ observer: true, components: { read: [Report.errors], changed: [Report.errors] } }, row => {
+const logFailures = System.create({ observer: true, components: { with: [Report.errors], changed: [Report.errors] } }, row => {
for (const failure of row[Report.errors].failures) logged.push(`${failure.name}: ${failure.message}`);
});
-world.add(logFailures);
-const run: number = await world.run(fail);
-expect(run).toBe(1);
-expect(logged).toEqual(["fail: broken"]);
+it("reports what failed as entities", async () => {
+ await using world = World.create();
+ world.add(logFailures);
+ const run: number = await world.run(fail);
+ expect(run).toBe(1);
+ expect(logged).toEqual(["fail: broken"]);
```
-[`03-systems.test.ts`](03-systems.test.ts#L71-L87)
+[`03-systems.test.ts`](03-systems.test.ts#L81-L98)
### 4. Ticks and cadence
```ts
// clock is the current tick's: delta is the milliseconds since the last one.
-const advance = System.create({ resources: { read: [clock], write: [elapsed] } }, ({ resources }) => {
+const advance = System.create({}, ({ resources }) => {
resources[elapsed] += resources[clock].delta;
});
// Added, an observer system reacts to changes: with elapsed in changed, it runs after each tick that changed it.
let reached: (value: number) => void = () => {};
-const watch = System.create({ observer: true, resources: { read: [elapsed], changed: [elapsed] } }, ({ resources }) => {
+const watch = System.create({ observer: true, resources: { changed: [elapsed] } }, ({ resources }) => {
if (resources[elapsed] >= 30) reached(resources[elapsed]);
});
-// cadence ticks once per animation frame by default; fixed ticks every step milliseconds, each with that delta.
-// A tick runs only after a run arrives, unless always is set.
-await using world = World.create({ [cadence]: { kind: "fixed", step: 10 }, [always]: true });
-const ticked = new Promise(resolve => { reached = resolve; });
-// add: runs a system on every tick.
-world.add(advance);
-world.add(watch);
-expect(await ticked).toBeGreaterThanOrEqual(30);
-// remove: takes a system added off every tick again.
-world.remove(advance);
+let read = { elapsed: 0, delta: 0 };
+const readTime = System.create({ observer: true }, ({ resources }) => {
+ read = { elapsed: resources[elapsed], delta: resources[clock].delta };
+});
+
+it("runs added systems on every tick", async () => {
+ // cadence ticks once per animation frame by default; fixed ticks every step milliseconds, each with that delta.
+ // A tick runs only after a run arrives, unless always is set.
+ await using world = World.create({ [cadence]: { kind: "fixed", step: 10 }, [always]: true });
+ const ticked = new Promise(resolve => { reached = resolve; });
+ // add: runs a system on every tick.
+ world.add(advance);
+ world.add(watch);
+ expect(await ticked).toBeGreaterThanOrEqual(30);
+ await world.run(readTime);
+ expect(read.delta).toBe(10);
+ // remove: takes a system added off every tick again.
+ world.remove(advance);
```
-[`04-ticks.test.ts`](04-ticks.test.ts#L18-L52)
+[`04-ticks.test.ts`](04-ticks.test.ts#L15-L43)
```ts
// A system asks for another tick by contributing a time to wake: the first tick at or after it runs. Work that a tick's
// commands start and a later tick finishes asks for the next one this way.
-const step = System.create({ resources: { read: [clock], write: [steps] }, reduce: { [wake]: "min" } }, ({ resources, reduce }) => {
+const step = System.create({}, ({ resources, reduce }) => {
resources[steps] += 1;
if (resources[steps] < 3) reduce[wake](resources[clock].now);
});
-await using world = World.create({ [cadence]: { kind: "fixed", step: 10 } });
-world.add(step);
-// One run arriving ticks once; step's wakes tick twice more.
-await world.run(System.create(() => {}));
+let finished: (value: number) => void = () => {};
+const watchSteps = System.create({ observer: true, resources: { changed: [steps] } }, ({ resources }) => {
+ if (resources[steps] === 3) finished(resources[steps]);
+});
+
+it("ticks again when a system asks", async () => {
+ await using world = World.create({ [cadence]: { kind: "fixed", step: 10 } });
+ const done = new Promise(resolve => { finished = resolve; });
+ world.add(step);
+ world.add(watchSteps);
+ // One run arriving ticks once; step's wakes tick twice more.
+ await world.run(System.create(() => {}));
```
-[`04-ticks.test.ts`](04-ticks.test.ts#L54-L75)
+[`04-ticks.test.ts`](04-ticks.test.ts#L51-L69)
## Intermediate
### 5. Scheduling and composites
```ts
-const hit = System.create({ components: { read: [damage], write: [health] } }, row => {
+const hit = System.create({ components: { with: [damage, health] } }, row => {
row[health] -= row[damage];
});
// before, after and during list the systems themselves.
-const heal = System.create({ components: { write: [health] }, after: [hit] }, row => {
+const heal = System.create({ components: { with: [health] }, after: [hit] }, row => {
row[health] = Math.min(row[health] * 2, 10);
});
// A list of systems is a composite: it runs its members as one system, in their order; members without an order
-// between them may run in parallel. { systems, after, ... } declares one with a name or constraints.
+// between them may run in parallel. { systems, after, ... } declares one with constraints.
const frame = System.create([heal, hit]);
```
-[`05-scheduling.test.ts`](05-scheduling.test.ts#L19-L43)
+[`05-scheduling.test.ts`](05-scheduling.test.ts#L16-L25)
```ts
// Phases are systems that do nothing; a system during one runs after what the phase follows and before what follows it.
const update = System.create(() => {});
const render = System.create({ after: [update] }, () => {});
-const poison = System.create({ components: { write: [health] }, during: [update] }, row => {
+const poison = System.create({ components: { with: [health] }, during: [update] }, row => {
row[health] -= 1;
});
-const report = System.create({ components: { read: [health] }, during: [render] }, row => {
+const seen: number[] = [];
+const report = System.create({ components: { with: [health] }, during: [render] }, row => {
seen.push(row[health]);
});
const phased = System.create([report, poison, update, render]);
```
-[`05-scheduling.test.ts`](05-scheduling.test.ts#L45-L62)
+[`05-scheduling.test.ts`](05-scheduling.test.ts#L41-L51)
### 6. Sync points
@@ -292,112 +351,139 @@ const spawn = System.create(({ commands }) => {
commands.despawn(commands.spawn([health])({ [health]: 1 }));
});
// A composite is one run with one sync point: count runs after spawn, before its entities exist.
-const count = System.create({ components: { has: [health] }, resources: { write: [seen] }, after: [spawn] }, (row, { resources }) => {
+const count = System.create({ components: { with: [health] }, after: [spawn] }, (row, { resources }) => {
resources[seen] += 1;
});
const frame = System.create([spawn, count]);
```
-[`06-sync-points.test.ts`](06-sync-points.test.ts#L21-L53)
+[`06-sync-points.test.ts`](06-sync-points.test.ts#L17-L29)
```ts
// An observer runs after the sync point of every run and tick it's added to, reading what it left; it writes nothing.
-const watch = System.create({ observer: true, components: { read: [health] } }, row => {
+const watch = System.create({ observer: true, components: { with: [health] } }, row => {
healths.push(row[health]);
});
```
-[`06-sync-points.test.ts`](06-sync-points.test.ts#L36-L39)
+[`06-sync-points.test.ts`](06-sync-points.test.ts#L32-L35)
### 7. Change filters and conditions
```ts
// A resource in changed passes the first run and each run after it changes: no run-once flag needed.
-const load = System.create({ resources: { read: [level], changed: [level] } }, ({ resources, commands }) => {
+const load = System.create({ resources: { changed: [level] } }, ({ resources, commands }) => {
commands.spawn([score, shown, boss])({ [score]: 0, [shown]: -1 });
for (let i = 0; i < resources[level]; i++) commands.spawn([score, shown])({ [score]: 0, [shown]: -1 });
});
// A component in changed passes only rows written since this system last ran.
-const show = System.create({ components: { read: [score], write: [shown], changed: [score] } }, row => {
+const show = System.create({ components: { with: [score, shown], changed: [score] } }, row => {
row[shown] = row[score];
});
```
-[`07-change-filters.test.ts`](07-change-filters.test.ts#L36-L66)
+[`07-change-filters.test.ts`](07-change-filters.test.ts#L27-L35)
```ts
// Runs only while each resource in is is neither null nor false, and each in not is; is reads it narrowed.
-const step = System.create({ resources: { is: [map], not: [paused], write: [steps] } }, ({ resources }) => {
+const step = System.create({ resources: { is: [map], not: [paused] } }, ({ resources }) => {
resources[steps] += resources[map].width;
});
```
-[`07-change-filters.test.ts`](07-change-filters.test.ts#L68-L89)
+[`07-change-filters.test.ts`](07-change-filters.test.ts#L59-L62)
### 8. Input
```ts
-// input is an object schema of named fields; each run passes a value of it.
+// A body's input is its parameter after its context, an object of named fields; each run passes a value of it.
const hit = System.create({
- components: { write: [health] },
- input: { type: "object", properties: { amount: { type: "number" } } },
-}, (row, { input }) => {
+ components: { with: [health] },
+}, (row, _context, input: { readonly amount: number }) => {
row[health] -= input.amount;
});
// target names an input field holding an entity: the system runs on that entity's row alone, or on none once it's gone.
const heal = System.create({
- components: { write: [health] },
- input: { type: "object", properties: { entity: Entity.schema, amount: { type: "number" } } },
+ components: { with: [health] },
target: "entity",
-}, (row, { input }) => {
+}, (row, _context, input: { readonly entity: Entity; readonly amount: number }) => {
row[health] += input.amount;
});
-// Systems with input are run, not added; a composite's input has every field its members declare.
-await world.run(hit, { amount: 3 });
-await world.run(heal, { entity: player, amount: 1 });
+const healths: number[] = [];
+const read = System.create({ observer: true, components: { with: [health] } }, row => {
+ healths.push(row[health]);
+});
+
+it("runs a system with a value", async () => {
+ await using world = World.create();
+ await world.run(spawn);
+ // Systems with input are run, not added; a composite's input has every field its members declare.
+ await world.run(hit, { amount: 3 });
+ await world.run(heal, { entity: player, amount: 1 });
```
-[`08-input.test.ts`](08-input.test.ts#L19-L49)
+[`08-input.test.ts`](08-input.test.ts#L16-L41)
### 9. Rules
```ts
declare module "@adobe/ecs" {
interface Components {
- // Only rules add derived components, starting at their schema's default.
+ [mesh]: { type: number };
+ [sprite]: { type: number };
+ [hidden]: { type: true };
+ // Only rules add derived components, starting at their default.
[drawn]: { type: true; derived: true };
- [layer]: { type: number; derived: true };
+ [layer]: { type: number; derived: true; default: 1 };
}
}
-Component.register(drawn, { type: { const: true }, derived: true });
-Component.register(layer, { type: { type: "number", default: 1 }, derived: true });
-
// Entities matching a rule have what it derives, only while they match.
-Rule.create({ components: { has: [mesh], not: [hidden] }, derive: [drawn] });
+Rule.create({ components: { with: [mesh], not: [hidden] }, derive: [drawn] });
// Several rules may derive one component, present while any matches.
-Rule.create({ components: { has: [sprite], not: [hidden] }, derive: [drawn] });
+Rule.create({ components: { with: [sprite], not: [hidden] }, derive: [drawn] });
// Rules build on what others derive.
-Rule.create({ components: { has: [drawn] }, derive: [layer] });
+Rule.create({ components: { with: [drawn] }, derive: [layer] });
```
-[`09-rules.test.ts`](09-rules.test.ts#L11-L59)
+[`09-rules.test.ts`](09-rules.test.ts#L11-L27)
### 10. Finalizers and change sets
```ts
// Runs for each entity that stops matching, whatever the cause, reading its values from before.
-Finalizer.create({ components: { read: [handle] } }, row => {
+Finalizer.create({ components: { with: [handle] } }, row => {
open.delete(row[handle]);
});
-// dispose runs every finalizer for the entities it still matches.
-await world.dispose();
+const spawn = System.create(({ commands }) => {
+ for (const id of [7, 8]) {
+ open.add(id);
+ commands.spawn([handle])({ [handle]: id });
+ }
+});
+const destroy = System.create({ components: { with: [handle] } }, (row, { commands }) => {
+ if (row[handle] === 7) commands.despawn(row.entity);
+ else commands.remove(row.entity, handle);
+});
+
+it("releases what entities held when they stop matching", async () => {
+ await using world = World.create();
+ await world.run(spawn);
+ await world.run(destroy);
+ expect([...open]).toEqual([]);
+});
+
+it("releases what every entity holds when the world is disposed", async () => {
+ const world = World.create();
+ await world.run(spawn);
+ // dispose runs every finalizer for the entities it still matches.
+ await world.dispose();
```
-[`10-finalizers.test.ts`](10-finalizers.test.ts#L16-L48)
+[`10-finalizers.test.ts`](10-finalizers.test.ts#L16-L43)
```ts
// Sees each sync point's structural changes to the components it watches, grouped by how entities changed, with
@@ -409,12 +495,12 @@ Finalizer.changes({ watch: [handle] }, changes => {
});
```
-[`10-finalizers.test.ts`](10-finalizers.test.ts#L50-L66)
+[`10-finalizers.test.ts`](10-finalizers.test.ts#L49-L55)
### 11. Traits, within and references
```ts
-// Traits are the fields of a key's declaration; type is one. Registration repeats them.
+// Traits are the fields of a key's declaration; type is one.
declare module "@adobe/ecs" {
interface Components {
[ship]: { type: true };
@@ -428,12 +514,6 @@ declare module "@adobe/ecs" {
}
}
-Component.register(ship, { type: { const: true } });
-Component.register(parent, { type: Entity.schema, reference: "owner" });
-Component.register(target, { type: Entity.schema, reference: "weak" });
-Component.register(selected, { type: { const: true } });
-Component.register(outline, { type: { type: "number" }, within: selected });
-
const spawn = System.create(({ commands }) => {
const mothership = commands.spawn([ship, selected, outline])({ [outline]: 2 });
commands.spawn([parent])({ [parent]: mothership });
@@ -441,111 +521,331 @@ const spawn = System.create(({ commands }) => {
});
```
-[`11-traits.test.ts`](11-traits.test.ts#L12-L60)
+[`11-traits.test.ts`](11-traits.test.ts#L11-L29)
```ts
// A trait of your own is a member of Traits: the value it allows on key N, or never where it doesn't apply.
-export const instanced = Symbol("instanced");
+export const instanced = Symbol();
declare module "@adobe/ecs" {
interface Traits {
readonly [instanced]: N extends Component.Key ? ([Component.Value] extends [readonly number[]] ? true : never) : never;
}
}
-Trait.register(instanced);
+
+const transform = Symbol();
+const name = Symbol();
+declare module "@adobe/ecs" {
+ interface Components {
+ [transform]: { type: readonly [number, number, number]; [instanced]: true };
+ [name]: { type: string };
+ }
+}
// Types select components by trait.
check, typeof transform | typeof name>, typeof transform>>();
```
-[`11-traits.type-test.ts`](11-traits.type-test.ts#L7-L28)
+[`11-traits.type-test.ts`](11-traits.type-test.ts#L7-L25)
## Advanced
### 12. Lookups
-Reading another entity's components by id, from a system. To be written.
+A lookup reads other entities from a system: by id, as here, or by value through an index (next section). It's named in
+the declaration and called from the body, it's read-only, and it doesn't change which entities the system runs on.
+
+```ts
+// The entity's row, or null when it doesn't have every component in with, or has one in not. The row holds only what
+// with lists.
+const track = System.create({
+ components: { with: [target, aim] },
+ lookup: { enemy: { with: [health], not: [frozen] } },
+}, (row, { lookup }) => {
+ const enemy = lookup.enemy(row[target]);
+ row[aim] = enemy === null ? 0 : enemy[health];
+});
+```
+
+[`12-lookups.test.ts`](12-lookups.test.ts#L19-L27)
+
+```ts
+// With changed, its row says whether those components may have changed since the system last ran.
+const refreshed: boolean[] = [];
+const watch = System.create({
+ observer: true,
+ components: { with: [target] },
+ lookup: { enemy: { with: [health], changed: [health] } },
+}, (row, { lookup }) => {
+ const enemy = lookup.enemy(row[target]);
+ if (enemy !== null) refreshed.push(enemy.changed);
+});
+```
+
+[`12-lookups.test.ts`](12-lookups.test.ts#L29-L38)
+
+To find entities by their values rather than their ids, look up through an index (next section).
### 13. Indexes
+An index is a declared map from component values to the entities holding them, which every world keeps current. A
+lookup through it finds entities by value, as a lookup by entity finds one by id.
+
+- A hash index, the default, gives the rows with a key.
+- `sorted: true` also takes ranges, and orders a system's rows with `order`.
+- `unique: true` gives one row, or null.
+- `key: []` holds every entity it matches.
+
```ts
-// An index finds entities by their components' values: here, those with a crowded count, by their cell.
-const grid = Index.create({ key: [cell], components: { has: [crowded] } });
+// Here, entities with a crowded count, by their cell.
+const grid = Index.create({ key: [cell], components: { with: [crowded] } });
// A lookup through it gives the rows with a key, in entity order.
-const count = System.create({ components: { read: [cell], write: [crowded] }, lookup: { at: { index: grid } } }, (row, { lookup }) => {
+const count = System.create({ components: { with: [cell, crowded] }, lookup: { at: { index: grid } } }, (row, { lookup }) => {
const [x, y] = row[cell];
row[crowded] = lookup.at([x, y]).length;
});
```
-[`13-indexes.test.ts`](13-indexes.test.ts#L28-L35)
+[`13-indexes.test.ts`](13-indexes.test.ts#L21-L28)
```ts
// A sorted index keeps its key's order: lookups take a range, and `order` runs a system's rows in it.
-const byScore = Index.create({ key: [score], sorted: true, components: { has: [player] } });
+const byScore = Index.create({ key: [score], sorted: true, components: { with: [player] } });
const winners: string[] = [];
-const leaders = System.create({ observer: true, lookup: { scores: { index: byScore, read: [player] } } }, ({ lookup }) => {
+const leaders = System.create({ observer: true, lookup: { scores: { index: byScore } } }, ({ lookup }) => {
winners.length = 0;
const high = lookup.scores({ ">=": 90 });
for (let i = 0; i < high.length; i++) winners.push(high.at(i)[player]);
});
const board: string[] = [];
-const list = System.create({ observer: true, components: { read: [player] }, order: byScore }, row => {
+const list = System.create({ observer: true, components: { with: [player] }, order: byScore }, row => {
board.push(row[player]);
});
```
-[`13-indexes.test.ts`](13-indexes.test.ts#L37-L50)
+[`13-indexes.test.ts`](13-indexes.test.ts#L30-L43)
```ts
// A unique index gives one row, or null.
const byServerId = Index.create({ key: [serverId], unique: true });
let found: number | null = null;
-const find = System.create({ input: { type: "object", properties: { id: { type: "integer" } } }, lookup: { byId: { index: byServerId } } }, ({ input, lookup }) => {
+const find = System.create({ lookup: { byId: { index: byServerId } } }, ({ lookup }, input: { readonly id: number }) => {
found = lookup.byId(input.id)?.entity ?? null;
});
```
-[`13-indexes.test.ts`](13-indexes.test.ts#L52-L57)
+[`13-indexes.test.ts`](13-indexes.test.ts#L45-L50)
```ts
// An index without a key holds every entity with its components, in entity order. With changed: true, this observer
// system runs only when an entity entered or left the index, so added, it keeps a count as players come and go.
-const players = Index.create({ key: [], components: { has: [player] } });
+const players = Index.create({ key: [], components: { with: [player] } });
let playerCount = 0;
const countPlayers = System.create({ observer: true, lookup: { players: { index: players, changed: true } } }, ({ lookup }) => {
playerCount = lookup.players().length;
});
```
-[`13-indexes.test.ts`](13-indexes.test.ts#L59-L65)
+[`13-indexes.test.ts`](13-indexes.test.ts#L52-L58)
-A list stays valid until its call site runs again. Lists and rows read only what the index holds and its lookup's
-`read` names, and `list.changed` tells whether an entity entered, left or moved in it since the system last ran. A
-lookup's `changed: true` runs its system only when that happened anywhere in the index.
+A list has `length`, `changed` and `at(i)`. It's reused, valid until its call site runs again, each row as long as its
+list, and costs only what the body reads. Rows hold what every entity in the index has: its key and its `with`.
+`list.changed` says whether an entity entered, left or moved in the list since the system last ran; a lookup's
+`changed: true` runs its system only when that happened anywhere in the index.
### 14. Kernels
-Systems compiled to run across workers. To be written.
+```ts
+// A kernel is a body run once per row, each row on its own, so the world can split rows across workers and run them as
+// WASM. A body that reads and writes only structs, calls math and touches no module state is one: the build compiles
+// it, with results the same as the body as written.
+const move = System.create({ components: { with: [velocity, position] } }, (row, { resources }) => {
+ row[position] = Vec3.add(row[position], Vec3.scale(row[velocity], resources[delta]));
+});
+
+// kernel: true asserts it: the build fails, saying why, if the body can't be one. It also computes f32 values at f32,
+// which is faster on dense rows.
+const drift = System.create({ kernel: true, components: { with: [velocity, position] } }, row => {
+ row[position] = Vec3.add(row[position], row[velocity]);
+});
+
+// kernel: false runs the body as written, on its thread: to step through it in a debugger, say.
+const slow = System.create({ kernel: false, components: { with: [velocity] } }, row => {
+ row[velocity] = Vec3.scale(row[velocity], 0.5);
+});
+```
+
+[`14-kernels.test.ts`](14-kernels.test.ts#L19-L35)
### 15. Reductions
-One value computed from many rows. To be written.
+```ts
+declare module "@adobe/ecs" {
+ interface Components {
+ [mass]: { type: F32 };
+ }
+ // A reduced resource names how contributions combine: "+", "*", "min", "max", or bitwise "&", "|", "^".
+ interface Resources {
+ [total]: { type: number; reduce: "+" };
+ [heaviest]: { type: F32; reduce: "max" };
+ [count]: { type: number; reduce: "+" };
+ }
+}
+```
+
+[`15-reductions.test.ts`](15-reductions.test.ts#L10-L20)
+
+```ts
+// Each row contributes; every run starts each resource it reduces at its operator's identity and leaves it holding the
+// fold of what every row gave, however the rows were split across workers.
+const weigh = System.create({ components: { with: [mass] } }, (row, { reduce }) => {
+ reduce[total](row[mass]);
+ reduce[heaviest](row[mass]);
+ reduce[count](1);
+});
+
+// Read the result after the run, as any resource.
+let summary = "";
+const report = System.create({ observer: true }, ({ resources }) => {
+ summary = `${resources[count]} weigh ${resources[total]}, at most ${resources[heaviest]}`;
+});
+```
+
+[`15-reductions.test.ts`](15-reductions.test.ts#L22-L34)
### 16. GPU kernels and render systems
-Systems that run or draw on the GPU. To be written.
+```ts
+// kernel: "gpu" runs the body as a compute shader over the GPU's copy of its components, in a world given a device
+// (World.create({ [gpuDevice]: device })), and as a kernel on the CPU in one without. Its rows hold 32-bit numbers
+// and vectors of them; it has no lookups, change filters or commands.
+const move = System.create({ kernel: "gpu", components: { with: [velocity, position] } }, row => {
+ row[position] = Vec2.add(row[position], row[velocity]);
+});
+```
+
+[`16-gpu.test.ts`](16-gpu.test.ts#L17-L22)
+
+```ts
+// A render system records draws over the GPU copy of the components its accessors name, in a world with a device.
+// Gpu.wgsl(draw) gives its shaders a function per accessor, `fn dotPosition(row: u32) -> vec2`, and
+// Gpu.layout(device) the bind group layout its pipeline takes. Each table is a group of matching rows to draw.
+let pipeline: GPURenderPipeline | null = null;
+let target: GPUTextureView | null = null;
+const draw = System.render({ shader: { accessors: { dotPosition: position, dotSize: size } } }, ({ encoder, tables }) => {
+ if (pipeline === null || target === null) return;
+ const pass = encoder.beginRenderPass({ colorAttachments: [{ view: target, loadOp: "clear", storeOp: "store" }] });
+ pass.setPipeline(pipeline);
+ for (const table of tables) {
+ table.bind(pass);
+ pass.draw(6, table.count);
+ }
+ pass.end();
+});
+const shaderCode = Gpu.wgsl(draw);
+```
+
+[`16-gpu.test.ts`](16-gpu.test.ts#L24-L39)
### 17. Hierarchies
-Running rows parent first, through a reference. To be written.
+```ts
+declare module "@adobe/ecs" {
+ interface Components {
+ // Despawned with its parent.
+ [parent]: { type: Entity; reference: "owner" };
+ [offset]: { type: number };
+ [placed]: { type: number };
+ [label]: { type: string };
+ }
+}
+
+// up runs rows parent first, through a reference, so each row reads its parent's values from earlier in the same run:
+// context.up holds what the body reads of the parent, or null for a row without one.
+const place = System.create({ components: { with: [offset, placed] }, up: { through: parent } }, (row, { up }) => {
+ row[placed] = (up === null ? 0 : up[placed]) + row[offset];
+});
+```
+
+[`17-hierarchies.test.ts`](17-hierarchies.test.ts#L10-L24)
### 18. Identity and ids
-How systems and keys stay the same across reloads. To be written.
+```ts
+// A key, system, index, rule or finalizer is the value its constructor returns: two written alike are two, and code
+// written apart can't collide. The build gives each one bound at a module's top level an id from where it's declared,
+// its package's name and version, its module's path and its binding, so other threads and a module evaluated again
+// by hot reload name the same one.
+const explode = System.create(() => {
+ throw new Error("boom");
+});
+
+// Reports label it by its binding, and give the id beside it to tell two of one label apart.
+const failures: string[] = [];
+const logFailures = System.create({ observer: true, components: { with: [Report.errors], changed: [Report.errors] } }, row => {
+ for (const failure of row[Report.errors].failures) failures.push(`${failure.name} ${failure.id}`);
+});
+```
+
+[`18-identity.test.ts`](18-identity.test.ts#L5-L17)
### 19. Threads
-Sharing a world with another thread, which joins it to run systems and add observers. To be written.
+```ts
+// A logical thread is where objects live and the code touching them runs. Declare one, and place objects on it.
+declare module "@adobe/ecs" {
+ interface Threads {
+ audio: true;
+ }
+ interface Components {
+ [volume]: { type: number };
+ }
+ interface Resources {
+ [levels]: { type: ReadonlyMap; thread: "audio" };
+ }
+}
+
+// A system runs on the thread its objects live on: this one, writing levels, runs on audio.
+const mix = System.create({ components: { with: [volume] } }, (row, { resources }) => {
+ resources[levels] = new Map([...resources[levels], [`voice ${row.entity}`, row[volume]]]);
+});
+
+// Another thread joins a shared world, claiming the threads it fills, and adds observer systems that run there after
+// the world's sync points.
+const heard: number[] = [];
+const listen = System.create({ observer: true }, ({ resources }) => {
+ heard.push(resources[levels].size);
+});
+```
+
+[`19-threads.test.ts`](19-threads.test.ts#L8-L31)
+
+```ts
+const spawn = System.create(({ commands }) => {
+ commands.spawn([volume])({ [volume]: 0.8 });
+ commands.spawn([volume])({ [volume]: 0.3 });
+});
+
+/** Waits a turn of the event loop at a time until `done`. */
+const until = async (done: () => boolean): Promise => {
+ while (!done()) await new Promise(resolve => setTimeout(resolve, 1));
+};
+
+it("shares a world with another thread", async () => {
+ // Usually one end of the channel goes to a worker, `new Worker(...)`, which joins with the other.
+ const channel = new MessageChannel();
+ await using world = World.create();
+ world.share(channel.port1);
+ await using audio = World.join(channel.port2, { threads: ["audio"] });
+ audio.add(listen);
+ await world.run(spawn);
+ // A joined world runs systems as the world does.
+ await audio.run(mix);
+ await until(() => heard.at(-1) === 2);
+ expect(heard.at(-1)).toBe(2);
+});
+```
+
+[`19-threads.test.ts`](19-threads.test.ts#L33-L55)
diff --git a/packages/ecs/src/implementation/compiler/access.ts b/packages/ecs/src/implementation/compiler/access.ts
index 6ae9f6b81..c4169494a 100644
--- a/packages/ecs/src/implementation/compiler/access.ts
+++ b/packages/ecs/src/implementation/compiler/access.ts
@@ -50,9 +50,8 @@ export const access = (value: Value, key: Key, node: Node, scope: Scope, optiona
if (name === "lookup") return { kind: "lookups" };
if (name === "reduce") return { kind: "reducers" };
if (name === "commands") return { kind: "commands" };
- if (name === "input" && signature.input) return { kind: "dynamic", expression: { kind: "args", path: [] } };
if (name === "up" && scope.unit.up !== null) return { kind: "found", lookup: signature.lookups.length, expression: local(scope.unit.up) };
- return fail(node, "the context holds the declared resources, lookup, reduce, input and up, and commands");
+ return fail(node, "the context holds the declared resources, lookup, reduce and up, and commands");
case "resources": {
const index = key.kind === "binding" ? signature.resources.indexOf(key.name) : -1;
if (key.kind !== "binding" || index < 0) return fail(node, `${key.kind === "binding" ? key.name : "resources"} is not in resources read`);
@@ -61,7 +60,7 @@ export const access = (value: Value, key: Key, node: Node, scope: Scope, optiona
}
case "reducers": {
const reduced = key.kind === "binding" ? signature.reduced.findIndex(entry => entry.resource === key.name) : -1;
- return reduced >= 0 ? { kind: "reducer", reduced } : fail(node, "reduce is indexed by a resource in the declaration's reduce");
+ return reduced >= 0 ? { kind: "reducer", reduced } : fail(node, "reduce is indexed by a resource in the declaration's resources.reduce");
}
case "lookups": {
const lookup = signature.lookups.findIndex(entry => entry.name === name);
diff --git a/packages/ecs/src/implementation/compiler/archetypes.test.ts b/packages/ecs/src/implementation/compiler/archetypes.test.ts
new file mode 100644
index 000000000..016e9b034
--- /dev/null
+++ b/packages/ecs/src/implementation/compiler/archetypes.test.ts
@@ -0,0 +1,64 @@
+// © 2026 Adobe. MIT License. See /LICENSE for details.
+import { describe, expect, test } from 'vitest';
+import { parseAst } from 'vite';
+import { archetypeFunction, archetypeInsertions } from './archetypes.js';
+import { archetypeIndexKey } from '../../interface/registry/archetypes.js';
+import { insertInto } from './source-map.js';
+import { moduleOf } from './type-schemas.js';
+
+/** The module as the pass leaves it without the function it appends, its failure's message, or null. */
+const passed = (code: string): string | null => {
+ const result = archetypeInsertions(moduleOf("/src/lists.ts", code, parseAst(code, { lang: "ts" }, "/src/lists.ts")));
+ if (result === null) return null;
+ if ("failure" in result) return `failure: ${result.failure.message}`;
+ return insertInto(code, result.insertions, "/src/lists.ts").code.replace(/\nfunction archetype\$\d*\(list\) \{.*\}\n$/, "");
+};
+
+const keys = `import { label, type Other } from "./keys.js";\nconst todo = Symbol();\nconst done = Symbol();\n`;
+
+describe("archetypeInsertions", () => {
+ test("gives a top-level list of keys declared as const an index, its own keys or imported", () => {
+ expect(passed(`${keys}export const Todo = [todo, label, done] as const;`)).toBe(`${keys}export const Todo = archetype$([todo, label, done] as const);`);
+ });
+ test("gives an index to lists spreading lists of keys, and to lists in what the module makes as it loads", () => {
+ expect(passed(`${keys}const Node = [todo, done] as const;\nconst Kinds = [[...Node, label] as const, { list: [label] as const }];`))
+ .toBe(`${keys}const Node = archetype$([todo, done] as const);\nconst Kinds = [archetype$([...Node, label] as const), { list: archetype$([label] as const) }];`);
+ });
+ test("leaves a list spreading what isn't a list of keys", () => {
+ expect(passed(`${keys}const named = [todo];\nconst Node = [...named, done] as const;`)).toBe(null);
+ });
+ test("leaves lists it can't tell are only keys, or that aren't declared as const", () => {
+ expect(passed(`${keys}const loose = [todo, done];\nconst mixed = [todo, 1] as const;\nconst named = [todo, other] as const;\nconst empty = [] as const;`)).toBe(null);
+ });
+ test("leaves a list in a function, which isn't made once", () => {
+ expect(passed(`${keys}const make = () => [todo, done] as const;`)).toBe(null);
+ });
+ test("fails for a list naming one of its module's keys twice, and leaves one naming an import twice", () => {
+ expect(passed(`${keys}const Todo = [todo, done, todo] as const;`)).toBe("failure: this list names todo twice; a list of components names each once");
+ expect(passed(`${keys}const Labels = [label, label] as const;`)).toBe(null);
+ });
+ test("leaves a module binding a global the appended function uses", () => {
+ expect(passed(`${keys}const Object = 1;\nconst Todo = [todo, done] as const;`)).toBe(null);
+ });
+ test("gives an index to a list of a namespace's keys", () => {
+ expect(passed(`import { Report } from "@adobe/ecs";\nconst Reported = [Report.run, Report.errors] as const;`))
+ .toBe(`import { Report } from "@adobe/ecs";\nconst Reported = archetype$([Report.run, Report.errors] as const);`);
+ });
+ test("appends a function indexing a list of symbols each listed once, whether or not their declarations registered yet", () => {
+ const made: unknown = new Function(`${archetypeFunction("index")}\nreturn index;`)();
+ const index = (list: readonly unknown[]): unknown => typeof made === "function" ? Reflect.apply(made, undefined, [list]) : null;
+ const [first, second] = [Symbol(), Symbol()];
+ const list = [first, second];
+ expect(index(list)).toBe(list);
+ expect(typeof Reflect.get(list, archetypeIndexKey)).toBe("number");
+ const twice = [first, first];
+ index(twice);
+ expect(Reflect.get(twice, archetypeIndexKey)).toBe(undefined);
+ });
+ test("appends a function giving each list its index once", () => {
+ const code = `${keys}const Todo = [todo, done] as const;`;
+ const result = archetypeInsertions(moduleOf("/src/lists.ts", code, parseAst(code, { lang: "ts" }, "/src/lists.ts")));
+ const whole = result !== null && "insertions" in result ? insertInto(code, result.insertions, "/src/lists.ts").code : "";
+ expect(whole.match(/function archetype\$\(list\)/g)?.length).toBe(1);
+ });
+});
diff --git a/packages/ecs/src/implementation/compiler/archetypes.ts b/packages/ecs/src/implementation/compiler/archetypes.ts
new file mode 100644
index 000000000..00602378a
--- /dev/null
+++ b/packages/ecs/src/implementation/compiler/archetypes.ts
@@ -0,0 +1,134 @@
+// © 2026 Adobe. MIT License. See /LICENSE for details.
+import { archetypeCountName, archetypeIndexName } from '../../interface/registry/archetypes.js';
+import type { PassResult } from './input-schemas.js';
+import type { Insertion } from './source-map.js';
+import type { Module, Node, Statement } from './type-schemas.js';
+
+/**
+ * The function the plugin appends, as `name`, to a module with lists of keys it can read: it gives a list a
+ * program-wide index, by which worlds find what they resolved for it without comparing components, when every member is
+ * a symbol, each once, and returns the list. It runs where the list is made, before any key's declaration may be
+ * registered, so whether they're components is checked where the list is used. Generated code imports nothing, so it
+ * shares the count through a global, as every copy of the package does (`registry/archetypes.ts`).
+ */
+export const archetypeFunction = (name: string): string => [
+ `function ${name}(list) {`,
+ ` const index = Symbol.for(${JSON.stringify(archetypeIndexName)}), count = Symbol.for(${JSON.stringify(archetypeCountName)});`,
+ ` if (Object.prototype.hasOwnProperty.call(list, index) || !Object.isExtensible(list)) return list;`,
+ ` for (let k = 0; k < list.length; k++) if (typeof list[k] !== "symbol" || list.indexOf(list[k]) < k) return list;`,
+ ` const next = globalThis[count] || 0; globalThis[count] = next + 1; Object.defineProperty(list, index, { value: next });`,
+ ` return list;`,
+ `}`,
+].join("");
+
+const uniqueName = (code: string, base: string): string => {
+ let name = base;
+ for (let n = 1; code.includes(name); n++) name = `${base}${n}`;
+ return name;
+};
+
+type Declarations = Extract;
+type Expression = NonNullable;
+/** `[...] as const`. */
+type ConstList = Extract & { readonly expression: Extract };
+
+const isNode = (value: object): value is Node => typeof Reflect.get(value, "start") === "number" && typeof Reflect.get(value, "type") === "string";
+
+const isConstList = (node: Node): node is ConstList => {
+ if (node.type !== "TSAsExpression") return false;
+ const expression: unknown = Reflect.get(node, "expression");
+ const annotation: unknown = Reflect.get(node, "typeAnnotation");
+ const typeName: unknown = typeof annotation === "object" && annotation !== null ? Reflect.get(annotation, "typeName") : undefined;
+ return typeof expression === "object" && expression !== null && Reflect.get(expression, "type") === "ArrayExpression"
+ && typeof typeName === "object" && typeName !== null && Reflect.get(typeName, "type") === "Identifier" && Reflect.get(typeName, "name") === "const";
+};
+
+/** Nodes whose code runs when called, not when the module loads. */
+const functions: ReadonlySet = new Set(["ArrowFunctionExpression", "FunctionExpression", "FunctionDeclaration", "ClassDeclaration", "ClassExpression"]);
+
+/** A top-level statement's variable declaration, exported or not. */
+const variablesOf = (statement: Statement): Declarations | null => {
+ const declared = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement;
+ return declared?.type === "VariableDeclaration" ? declared : null;
+};
+
+/** Whether a module binds at top level a global the appended function uses. */
+const bindsGlobals = (module: Module): boolean => module.program.body.some(statement => {
+ const declared = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement;
+ const names = declared?.type === "VariableDeclaration" ? declared.declarations.map(declarator => declarator.id.type === "Identifier" ? declarator.id.name : "")
+ : (declared?.type === "FunctionDeclaration" || declared?.type === "ClassDeclaration") ? [declared.id?.name ?? ""]
+ : statement.type === "ImportDeclaration" ? statement.specifiers.map(specifier => specifier.local.name)
+ : [];
+ return names.some(name => name === "Symbol" || name === "globalThis" || name === "Object");
+});
+
+/**
+ * Gives each top-level list of keys declared `as const`, `const List = [a, b] as const`, a program-wide index: its
+ * members are the module's `Symbol()` constants or value imports, and the appended function indexes it once they're
+ * all declared components. `as const` makes it a readonly tuple, which nothing changes, as an indexed list must not.
+ * A list naming one of the module's keys twice fails; one naming an import twice is left without an index.
+ */
+export const archetypeInsertions = (module: Module): PassResult | null => {
+ const { code, program } = module;
+ if (!code.includes("as const") || bindsGlobals(module)) return null;
+ const keys = new Set();
+ const local = new Set();
+ for (const [name, imported] of module.imports) if (imported.value && imported.imported !== null) keys.add(name);
+ for (const statement of program.body) {
+ const variables = variablesOf(statement);
+ if (variables?.kind !== "const") continue;
+ for (const { id, init } of variables.declarations) {
+ if (id.type === "Identifier" && init?.type === "CallExpression" && init.callee.type === "Identifier" && init.callee.name === "Symbol") {
+ keys.add(id.name);
+ local.add(id.name);
+ }
+ }
+ }
+ if (keys.size === 0 && module.imports.size === 0) return null;
+ /** The module's top-level constants holding lists it indexes, which other lists may spread. */
+ const lists = new Set();
+ const insertions: Insertion[] = [];
+ const name = uniqueName(code, "archetype$");
+ /** Lists naming one of the module's keys twice, which fail the build. */
+ const repeated: { readonly name: string; readonly at: number }[] = [];
+ /** Whether a list declared `as const` names only keys: the module's or imported, or spreads lists of them. */
+ const indexes = (list: ConstList): boolean => {
+ const named: string[] = [];
+ for (const element of list.expression.elements) {
+ if (element?.type === "Identifier" && keys.has(element.name)) named.push(element.name);
+ // A namespace's member, `Report.run`, is a key of the package or a module imported.
+ else if (element?.type === "MemberExpression" && !element.computed && element.object.type === "Identifier" && element.property.type === "Identifier"
+ && module.imports.get(element.object.name)?.value === true) named.push(`${element.object.name}.${element.property.name}`);
+ else if (element?.type !== "SpreadElement" || element.argument.type !== "Identifier" || !(lists.has(element.argument.name) || module.imports.get(element.argument.name)?.value === true)) return false;
+ }
+ const twice = named.find((member, k) => named.indexOf(member) < k);
+ if (twice === undefined) return true;
+ if (local.has(twice)) repeated.push({ name: twice, at: list.start });
+ return false;
+ };
+ // Lists made when the module loads: those in functions are made on every call, so they aren't indexed.
+ const visit = (node: unknown, binding: string | null): void => {
+ if (Array.isArray(node)) {
+ for (const item of node) visit(item, null);
+ return;
+ }
+ if (typeof node !== "object" || node === null || !isNode(node) || functions.has(node.type)) return;
+ if (isConstList(node) && node.expression.elements.length > 0 && indexes(node)) {
+ insertions.push({ at: node.start, text: `${name}(` }, { at: node.end, text: ")" });
+ if (binding !== null) lists.add(binding);
+ return;
+ }
+ for (const key of Object.keys(node)) if (key !== "parent") visit(Reflect.get(node, key), null);
+ };
+ for (const statement of program.body) {
+ const variables = variablesOf(statement);
+ // A list a top-level `const` holds may be spread by those after it.
+ if (variables?.kind === "const") for (const { id, init } of variables.declarations) visit(init, id.type === "Identifier" ? id.name : null);
+ else visit(statement, null);
+ }
+ const [twice] = repeated;
+ if (twice !== undefined) return { failure: { message: `this list names ${twice.name} twice; a list of components names each once`, position: twice.at, frames: [] }, dependencies: [] };
+ if (insertions.length === 0) return null;
+ insertions.push({ at: code.length, text: `\n${archetypeFunction(name)}\n` });
+ return { insertions, dependencies: [] };
+};
diff --git a/packages/ecs/src/implementation/compiler/augmentations.test.ts b/packages/ecs/src/implementation/compiler/augmentations.test.ts
new file mode 100644
index 000000000..ebf725d4f
--- /dev/null
+++ b/packages/ecs/src/implementation/compiler/augmentations.test.ts
@@ -0,0 +1,410 @@
+// © 2026 Adobe. MIT License. See /LICENSE for details.
+import { describe, expect, test } from 'vitest';
+import { parseAst } from 'vite';
+import { augmentationInsertions, registeredReductionsIn } from './augmentations.js';
+import { insertInto } from './source-map.js';
+import { hostOf } from './type-host.test-helper.js';
+import { moduleOf } from './type-schemas.js';
+
+const header = `import { Entity, type F32, type U32, type Vec3 } from "@adobe/ecs";\n`;
+
+/** The module as the pass leaves it, or its failure's message. */
+const passedWhole = async (body: string, files: { readonly [path: string]: string } = {}): Promise => {
+ const code = header + body;
+ const host = hostOf(files);
+ const result = await augmentationInsertions(moduleOf("/src/keys.ts", code, host.parse(code, "/src/keys.ts")), host);
+ if (result === null) return null;
+ if ("failure" in result) return `failure: ${result.failure.message}`;
+ return insertInto(code, result.insertions, "/src/keys.ts").code;
+};
+
+/** The module as the pass leaves it without the function it appends, or its failure's message. */
+const passed = async (body: string, files: { readonly [path: string]: string } = {}): Promise =>
+ (await passedWhole(body, files))?.replace(/\nfunction declare\$\d*\(key, kind, declaration\) \{.*\}\n$/, "") ?? null;
+
+/** The declarations the pass records, one per line, without the function recording them. */
+const registrations = async (body: string, files: { readonly [path: string]: string } = {}): Promise => {
+ const code = await passed(body, files);
+ if (code === null || code.startsWith("failure: ")) return code;
+ return code.split("\n").filter(line => /^declare\$\(/.test(line));
+};
+
+const keys = (...names: readonly string[]) => names.map(name => `const ${name} = Symbol();`).join("\n") + "\n";
+
+describe("augmentationInsertions", () => {
+ test("registers each component and resource with the schema of its type", async () => {
+ expect(await registrations(`${keys("position", "done", "label", "speed", "count")}declare module "@adobe/ecs" {
+ interface Components { [position]: { type: Vec3 }; [done]: { type: boolean }; [label]: { type: string } }
+ interface Resources { [speed]: { type: F32 | null }; [count]: { type: U32 | null } }
+}`)).toEqual([
+ `declare$(position, "component", { type: {"type":"array","items":{"type":"number","precision":1},"minItems":3,"maxItems":3} });`,
+ `declare$(done, "component", { type: {"type":"boolean"} });`,
+ `declare$(label, "component", { type: {"type":"string"} });`,
+ `declare$(speed, "resource", { type: {"anyOf":[{"type":"number","precision":1},{"type":"null"}]} });`,
+ `declare$(count, "resource", { type: {"anyOf":[{"type":"integer","minimum":0,"maximum":4294967295},{"type":"null"}]} });`,
+ ]);
+ });
+ test("appends the function recording its declarations, which imports nothing", async () => {
+ const code = await passedWhole(`${keys("tag")}declare module "@adobe/ecs" { interface Components { [tag]: { type: true } } }`);
+ expect(code?.split("\n").filter(line => line.startsWith("import"))).toEqual([header.trimEnd()]);
+ expect(code?.trimEnd().split("\n").at(-1)).toBe(`function declare$(key, kind, declaration) { (globalThis[Symbol.for("@adobe/ecs/declarations@1")] ??= new Map()).set(key, { kind, declaration }); }`);
+ });
+ test("names the function apart from the module's own names", async () => {
+ const code = await passedWhole(`const declare$ = 1;\n${keys("tag")}declare module "@adobe/ecs" { interface Components { [tag]: { type: true } } }`);
+ expect(code?.split("\n").filter(line => line.includes("declare$1"))).toEqual([
+ `declare$1(tag, "component", { type: {"const":true} });`,
+ `function declare$1(key, kind, declaration) { (globalThis[Symbol.for("@adobe/ecs/declarations@1")] ??= new Map()).set(key, { kind, declaration }); }`,
+ ]);
+ });
+ test("fails for a module binding a global the recording function uses", async () => {
+ for (const binding of [`const Map = 1;`, `function Symbol() {}`, `import { globalThis } from "./globals.js";`]) {
+ expect(await passed(`${binding}\n${keys("tag")}declare module "@adobe/ecs" { interface Components { [tag]: { type: true } } }`)).toContain("which the code recording its declarations uses");
+ }
+ });
+ test("gives a literal default beside the schema", async () => {
+ expect(await registrations(`${keys("gravity", "mode", "origin")}declare module "@adobe/ecs" {
+ interface Resources {
+ [gravity]: { type: F32; default: -9.8 };
+ [mode]: { type: "all" | "done"; default: "all" };
+ [origin]: { type: Vec3; default: [1, 2, 3] };
+ }
+}`)).toEqual([
+ `declare$(gravity, "resource", { type: {"type":"number","precision":1}, default: -9.8 });`,
+ `declare$(mode, "resource", { type: {"anyOf":[{"const":"all"},{"const":"done"}]}, default: "all" });`,
+ `declare$(origin, "resource", { type: {"type":"array","items":{"type":"number","precision":1},"minItems":3,"maxItems":3}, default: [1,2,3] });`,
+ ]);
+ });
+ test("derives a resource's or derived component's default from its type: its namespace's, null, a zero value, or a fresh ReadonlyMap or ReadonlySet", async () => {
+ expect(await registrations(`${keys("layer", "drawn", "speed", "count", "on", "text", "at", "stats", "focus", "frame", "list", "maybe", "names", "tags", "cache", "audio")}declare module "@adobe/ecs" {
+ interface Components {
+ [layer]: { type: number; derived: true };
+ [drawn]: { type: true; derived: true };
+ [speed]: { type: number };
+ }
+ interface Resources {
+ [count]: { type: U32 };
+ [on]: { type: boolean };
+ [text]: { type: string };
+ [at]: { type: Vec3 };
+ [stats]: { type: { readonly sprites: U32; readonly frames: U32 } };
+ [focus]: { type: Entity };
+ [frame]: { type: 0 | 1 | 2 };
+ [list]: { type: readonly string[] };
+ [maybe]: { type: F32 | null };
+ [names]: { type: ReadonlyMap };
+ [tags]: { type: ReadonlySet };
+ [cache]: { type: Map | null; thread: "main" };
+ [audio]: { type: AudioContext | null };
+ }
+}`)).toEqual([
+ `declare$(layer, "component", { type: {"type":"number"}, default: 0, derived: true });`,
+ `declare$(drawn, "component", { type: {"const":true}, derived: true });`,
+ `declare$(speed, "component", { type: {"type":"number"} });`,
+ // U32 and Vec3 are imported as types, so their namespaces' defaults are written in; Entity is a value.
+ `declare$(count, "resource", { type: {"type":"integer","minimum":0,"maximum":4294967295}, default: 0 });`,
+ `declare$(on, "resource", { type: {"type":"boolean"}, default: false });`,
+ `declare$(text, "resource", { type: {"type":"string"}, default: "" });`,
+ `declare$(at, "resource", { type: {"type":"array","items":{"type":"number","precision":1},"minItems":3,"maxItems":3}, default: [0,0,0] });`,
+ `declare$(stats, "resource", { type: {"type":"object","properties":{"sprites":{"type":"integer","minimum":0,"maximum":4294967295},"frames":{"type":"integer","minimum":0,"maximum":4294967295}}}, default: {"sprites":0,"frames":0} });`,
+ `declare$(focus, "resource", { type: {"type":"integer","format":"entity"}, default: Entity.default });`,
+ `declare$(frame, "resource", { type: {"anyOf":[{"const":0},{"const":1},{"const":2}]}, default: 0 });`,
+ `declare$(list, "resource", { type: {"type":"array","items":{"type":"string"}}, default: [] });`,
+ `declare$(maybe, "resource", { type: {"anyOf":[{"type":"number","precision":1},{"type":"null"}]} });`,
+ `declare$(names, "resource", { type: {"type":"map","keys":{"type":"string"},"values":{"type":"number"}}, fresh: "Map" });`,
+ `declare$(tags, "resource", { type: {"type":"set","items":{"type":"string"}}, fresh: "Set" });`,
+ `declare$(cache, "resource", { thread: "main" });`,
+ `declare$(audio, "resource", {});`,
+ ]);
+ });
+ test("takes its type's namespace's default, after the value holding it, unless a literal is given", async () => {
+ const files = { "/src/bounds.ts": `import type { F32 } from "@adobe/ecs";\nexport type Upper = F32;\nexport const Upper = { default: Infinity } as const;` };
+ const code = await passed(`import { Upper } from "./bounds.js";\n${keys("low", "high", "given", "mark")}declare module "@adobe/ecs" {
+ interface Components { [mark]: { type: Lower } }
+ interface Resources { [low]: { type: Lower }; [high]: { type: Upper }; [given]: { type: Lower; default: 1 } }
+}
+type Lower = F32;
+const Lower = { default: -Infinity } as const;
+const after = 1;`, files);
+ expect(code?.split("\n").filter(line => line.startsWith("declare$"))).toEqual([
+ `declare$(high, "resource", { type: {"type":"number","precision":1}, default: Upper.default });`,
+ `declare$(given, "resource", { type: {"type":"number","precision":1}, default: 1 });`,
+ `declare$(mark, "component", { type: {"type":"number","precision":1} });`,
+ `declare$(low, "resource", { type: {"type":"number","precision":1}, default: Lower.default });`,
+ ]);
+ expect(code?.split("\n").slice(-3)).toEqual([
+ `const Lower = { default: -Infinity } as const;`,
+ `declare$(low, "resource", { type: {"type":"number","precision":1}, default: Lower.default });`,
+ `const after = 1;`,
+ ]);
+ });
+ test("takes the package's types' namespaces' defaults: identities for a quaternion and a matrix", async () => {
+ expect(await registrations(`import { Quat, type Mat4x4 } from "@adobe/ecs";\n${keys("turn", "view")}declare module "@adobe/ecs" { interface Resources { [turn]: { type: Quat }; [view]: { type: Mat4x4 } } }`)).toEqual([
+ `declare$(turn, "resource", { type: {"type":"array","items":{"type":"number","precision":1},"minItems":4,"maxItems":4}, default: Quat.default });`,
+ `declare$(view, "resource", { type: {"type":"array","items":{"type":"number","precision":1},"minItems":16,"maxItems":16}, default: [1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1] });`,
+ ]);
+ });
+ test("starts a reduced resource at its operator's identity, within its type's range", async () => {
+ expect(await registrations(`${keys("sum", "product", "least", "most", "all", "any", "bounds", "masked")}declare module "@adobe/ecs" {
+ interface Resources {
+ [sum]: { type: F32; reduce: "+" };
+ [product]: { type: F32; reduce: "*" };
+ [least]: { type: F32; reduce: "min" };
+ [most]: { type: U32; reduce: "max" };
+ [all]: { type: boolean; reduce: "&" };
+ [any]: { type: boolean; reduce: "|" };
+ [bounds]: { type: { readonly low: F32; readonly high: F32 }; reduce: "max" };
+ [masked]: { type: U32; reduce: "&" };
+ }
+}`)).toEqual([
+ `declare$(sum, "resource", { type: {"type":"number","precision":1}, default: 0, reduce: "+" });`,
+ `declare$(product, "resource", { type: {"type":"number","precision":1}, default: 1, reduce: "*" });`,
+ `declare$(least, "resource", { type: {"type":"number","precision":1}, default: Infinity, reduce: "min" });`,
+ `declare$(most, "resource", { type: {"type":"integer","minimum":0,"maximum":4294967295}, default: 0, reduce: "max" });`,
+ `declare$(all, "resource", { type: {"type":"boolean"}, default: true, reduce: "&" });`,
+ `declare$(any, "resource", { type: {"type":"boolean"}, default: false, reduce: "|" });`,
+ `declare$(bounds, "resource", { type: {"type":"object","properties":{"low":{"type":"number","precision":1},"high":{"type":"number","precision":1}}}, default: {"low":-Infinity,"high":-Infinity}, reduce: "max" });`,
+ `declare$(masked, "resource", { type: {"type":"integer","minimum":0,"maximum":4294967295}, default: 4294967295, reduce: "&" });`,
+ ]);
+ });
+ test("takes a type's namespace's default only when it's deeply readonly, as a default is shared", async () => {
+ const declared = (holder: string) => `${keys("key")}declare module "@adobe/ecs" { interface Resources { [key]: { type: Settings } } }\ntype Settings = { readonly size: number };\n${holder}`;
+ const shared = "a default is shared";
+ expect(await registrations(declared(`const Settings = { default: { size: 1 } } as const;`))).toEqual([`declare$(key, "resource", { type: {"type":"object","properties":{"size":{"type":"number"}}}, default: Settings.default });`]);
+ expect(await registrations(declared(`const start: Readonly<{ size: number }> = { size: 2 };\nconst Settings = { default: start };`))).toEqual([`declare$(key, "resource", { type: {"type":"object","properties":{"size":{"type":"number"}}}, default: Settings.default });`]);
+ expect(await registrations(declared(`const start = { size: 2 } as const;\nconst Settings = { default: start };`))).toEqual([`declare$(key, "resource", { type: {"type":"object","properties":{"size":{"type":"number"}}}, default: Settings.default });`]);
+ expect(await passed(declared(`const Settings = { default: { size: 1 } };`))).toContain("is shared by every row and world that starts from it");
+ expect(await passed(declared(`const start = { size: 2 };\nconst Settings = { default: start } as const;`))).toContain("as const");
+ const maps = `${keys("key")}declare module "@adobe/ecs" { interface Resources { [key]: { type: Names } } }\ntype Names = ReadonlyMap;\n`;
+ expect(await passed(`${maps}const Names = { default: new Map([["a", 1]]) };`)).toContain("is shared by every row and world");
+ expect(await passed(`${maps}const names: Map = new Map();\nconst Names = { default: names };`)).toContain("ReadonlyMap");
+ expect(await registrations(`${maps}const names: ReadonlyMap = new Map([["a", 1]]);\nconst Names = { default: names };`)).toEqual([`declare$(key, "resource", { type: {"type":"map","keys":{"type":"string"},"values":{"type":"number"}}, default: Names.default });`]);
+ void shared;
+ });
+ test("takes a call in a readonly default when its function's declared return type is readonly", async () => {
+ const declared = (code: string, files: { readonly [path: string]: string } = {}) => registrations(`${keys("k")}declare module "@adobe/ecs" { interface Resources { [k]: { type: Note } } }\ntype Note = { readonly color: number; readonly tags: readonly string[] };\n${code}`, files);
+ const note = (call: string) => `const Note = { default: { color: ${call}, tags: [] } } as const;`;
+ expect(await declared(`function pack(r: number, g: number): number { return r + g; }\n${note("pack(255, 214)")}`)).toEqual([expect.stringContaining("default: Note.default")]);
+ expect(await declared(`import { pack } from "./color.js";\n${note("pack(255, 214)")}`, { "/src/color.ts": `import type { U32 } from "@adobe/ecs";\nexport const pack = (r: number, g: number): U32 => r + g;` })).toEqual([expect.stringContaining("default: Note.default")]);
+ expect(await declared(`const tagsOf = (): readonly string[] => [];\nconst Note = { default: { color: 1, tags: tagsOf() } } as const;`)).toEqual([expect.stringContaining("default: Note.default")]);
+ expect(await declared(`function pack(r: number, g: number) { return r + g; }\n${note("pack(255, 214)")}`)).toEqual(expect.stringMatching(/^failure: Note\.default is shared .*`pack\(255, 214\)` returns a type the build can't see is readonly: annotate its return type/));
+ expect(await declared(`const tagsOf = (): string[] => [];\nconst Note = { default: { color: 1, tags: tagsOf() } } as const;`)).toEqual(expect.stringMatching(/^failure: Note\.default is shared .*`tagsOf\(\)` returns a type that isn't readonly/));
+ });
+ test("writes a literal -0 as itself", async () => {
+ expect(await registrations(`${keys("floor")}declare module "@adobe/ecs" { interface Resources { [floor]: { type: number; default: -0 } } }`))
+ .toEqual([`declare$(floor, "resource", { type: {"type":"number"}, default: -0 });`]);
+ });
+ test("starts a struct's fields and a tuple's items at their own types' defaults", async () => {
+ const code = await passed(`import type { Schema } from "@adobe/ecs";\n${keys("level", "box", "pair")}declare module "@adobe/ecs" {
+ interface Resources {
+ [level]: { type: { readonly value: Level } };
+ [box]: { type: { readonly height: Height; readonly width: F32 } };
+ [pair]: { type: readonly [Height, F32] };
+ }
+}
+type Level = Schema.Type<{ type: "integer"; minimum: 1; maximum: 9 }>;
+const Level = { default: 5 } as const;
+type Height = F32;
+const Height = { default: -1 } as const;`);
+ expect(code?.split("\n").filter(line => line.startsWith("declare$"))).toEqual([
+ `declare$(level, "resource", { type: {"type":"object","properties":{"value":{"type":"integer","minimum":1,"maximum":9}}}, default: { "value": Level.default } });`,
+ `declare$(box, "resource", { type: {"type":"object","properties":{"height":{"type":"number","precision":1},"width":{"type":"number","precision":1}}}, default: { "height": Height.default, "width": 0 } });`,
+ `declare$(pair, "resource", { type: {"type":"array","items":{"type":"number","precision":1},"minItems":2,"maxItems":2}, default: [Height.default, 0] });`,
+ ]);
+ });
+ test("gives a mutable Map or Set no start of its own: null when its type allows it, else a failure", async () => {
+ expect(await registrations(`${keys("pool")}declare module "@adobe/ecs" { interface Resources { [pool]: { type: Pool | null } } }\ntype Pool = Map;`)).toEqual([`declare$(pool, "resource", {});`]);
+ expect(await passed(`${keys("pool")}declare module "@adobe/ecs" { interface Resources { [pool]: { type: Pool } } }\ntype Pool = Map;`)).toContain("a ReadonlyMap or ReadonlySet starts fresh in each world, a Map or Set doesn't");
+ });
+ test("gives a value that isn't data no schema, and its traits", async () => {
+ const files = { "/src/row.ts": `export class Row extends HTMLElement {}\nexport class Pool {}` };
+ expect(await registrations(`import { Row, Pool } from "./row.js";\n${keys("row", "pool", "canvas", "callback")}declare module "@adobe/ecs" {
+ interface Components { [row]: { type: Row; thread: "main" } }
+ interface Resources {
+ [pool]: { type: Pool | null; default: null };
+ [canvas]: { type: OffscreenCanvas | null; transfer: true };
+ [callback]: { type: (() => void) | null };
+ }
+}`, files)).toEqual([
+ `declare$(row, "component", { thread: "main" });`,
+ `declare$(pool, "resource", { default: null });`,
+ `declare$(canvas, "resource", { transfer: true });`,
+ `declare$(callback, "resource", {});`,
+ ]);
+ });
+ test("gives traits as declared, a trait of your own by its key, and registers traits `Traits` declares", async () => {
+ expect(await registrations(`${keys("instanced", "host", "outline", "owner", "drawn", "total")}declare module "@adobe/ecs" {
+ interface Traits { readonly [instanced]: true }
+ interface Components {
+ [host]: { type: true };
+ [outline]: { type: number; within: typeof host; [instanced]: true };
+ [owner]: { type: Entity; reference: "owner" };
+ [drawn]: { type: true; derived: true };
+ }
+ interface Resources { [total]: { type: F32; default: 0; reduce: "+" } }
+}`)).toEqual([
+ `declare$(instanced, "trait");`,
+ `declare$(host, "component", { type: {"const":true} });`,
+ `declare$(outline, "component", { type: {"type":"number"}, within: host, [instanced]: true });`,
+ `declare$(owner, "component", { type: {"type":"integer","format":"entity"}, reference: "owner" });`,
+ `declare$(drawn, "component", { type: {"const":true}, derived: true });`,
+ `declare$(total, "resource", { type: {"type":"number","precision":1}, default: 0, reduce: "+" });`,
+ ]);
+ });
+ test("registers a block's traits before the components declaring them", async () => {
+ expect(await registrations(`${keys("marked", "mark")}declare module "@adobe/ecs" {
+ interface Components { [marked]: { type: true; [mark]: true } }
+ interface Traits { readonly [mark]: true }
+}`)).toEqual([`declare$(mark, "trait");`, `declare$(marked, "component", { type: {"const":true}, [mark]: true });`]);
+ });
+ test("follows a type named by Schema.Type that another module declares", async () => {
+ const files = { "/src/units.ts": `import type { Schema } from "@adobe/ecs";\nexport type Level = Schema.Type<{ type: "integer"; minimum: 0; maximum: 9 }>;` };
+ expect(await registrations(`import type { Level } from "./units.js";\n${keys("level")}declare module "@adobe/ecs" { interface Components { [level]: { type: Level } } }`, files)).toEqual([
+ `declare$(level, "component", { type: {"type":"integer","minimum":0,"maximum":9} });`,
+ ]);
+ });
+ test("registers right after its key, and after the bindings its declaration names, wherever the block is", async () => {
+ const code = await passed(`declare module "@adobe/ecs" { interface Resources { [later]: { type: Start } } }
+const later = Symbol();
+type Start = Vec3;
+const Start = { default: [1, 2, 3] } as const;
+const after = 1;`);
+ expect(code?.split("\n").slice(-3)).toEqual([`const Start = { default: [1, 2, 3] } as const;`, `declare$(later, "resource", { type: {"type":"array","items":{"type":"number","precision":1},"minItems":3,"maxItems":3}, default: Start.default });`, `const after = 1;`]);
+ // Code between the key and a block below uses the key registered.
+ const above = await passed(`const speed = Symbol();\nconst uses = [speed];\ndeclare module "@adobe/ecs" { interface Resources { [speed]: { type: F32; default: 1 } } }`);
+ expect(above?.split("\n").slice(1, 3)).toEqual([`const speed = Symbol();`, `declare$(speed, "resource", { type: {"type":"number","precision":1}, default: 1 });`]);
+ });
+ test("registers a trait before what declares it, its keys declared below the blocks, the Traits block last", async () => {
+ const code = await passed(`declare module "@adobe/ecs" {
+ interface Components { [marked]: { type: true; [mark]: true } }
+}
+declare module "@adobe/ecs" {
+ interface Traits { readonly [mark]: true }
+}
+const marked = Symbol();
+const mark = Symbol();`);
+ expect(code?.split("\n").slice(-4)).toEqual([`const marked = Symbol();`, `const mark = Symbol();`, `declare$(mark, "trait");`, `declare$(marked, "component", { type: {"const":true}, [mark]: true });`]);
+ });
+ test("takes a type that allows null through its aliases", async () => {
+ const files = { "/src/maybe.ts": `export type Maybe = Pool | null;\nexport class Pool {}` };
+ expect(await registrations(`import type { Maybe } from "./maybe.js";\n${keys("pool")}declare module "@adobe/ecs" { interface Resources { [pool]: { type: Maybe } } }`, files)).toEqual([`declare$(pool, "resource", {});`]);
+ });
+ test("follows types a declare global block declares, here or in a module imported, before taking a name as a global class", async () => {
+ const files = { "/src/globals.ts": `declare global {\n interface Point { readonly x: number; readonly y: number }\n}\nexport {};` };
+ expect(await registrations(`import "./globals.js";\ndeclare global { type Weight = number }\n${keys("at", "weight")}declare module "@adobe/ecs" { interface Components { [at]: { type: Point }; [weight]: { type: Weight } } }`, files)).toEqual([
+ `declare$(at, "component", { type: {"type":"object","properties":{"x":{"type":"number"},"y":{"type":"number"}}} });`,
+ `declare$(weight, "component", { type: {"type":"number"} });`,
+ ]);
+ });
+ test("checks the built-in traits' values, as far as their literals and types tell", async () => {
+ const block = (body: string, more = "") => `${keys("key", "other")}const notKey = 1;\n${more}declare module "@adobe/ecs" { ${body} }`;
+ const components = (traits: string) => block(`interface Components { [key]: { ${traits} }; [other]: { type: true } }`);
+ expect(await passed(components(`type: true; derived: false`))).toContain("derived is `true`");
+ expect(await passed(block(`interface Resources { [key]: { type: number; default: 0; derived: true } }`))).toContain("only a component is derived");
+ expect(await passed(components(`type: Entity; reference: "strong"`))).toContain(`reference is "owner" or "weak"`);
+ expect(await passed(components(`type: number; reference: "owner"`))).toContain("a reference's type is Entity");
+ expect(await passed(components(`type: true; within: "other"`))).toContain("within is `typeof` a component's key");
+ expect(await passed(components(`type: true; within: typeof notKey`))).toContain("within is `typeof` a component's key");
+ expect(await passed(components(`type: Map; transfer: true`))).toContain("transfer takes a Transferable class");
+ expect(await passed(block(`interface Resources { [key]: { type: number; default: 0; reduce: "-" } }`))).toContain("reduce is one of");
+ expect(await passed(components(`type: number; reduce: "+"`))).toContain("only a resource is reduced");
+ expect(await passed(components(`type: string; thread: "main"`))).toContain("a string lives in shared memory");
+ expect(await passed(components(`type: Vec3; thread: "main"`))).toContain("a struct lives in shared memory");
+ // A Transferable class, through an alias and with null.
+ expect(await registrations(components(`type: Picture; transfer: true`).replace("const notKey", "type Picture = ImageBitmap | null;\nconst notKey"))).toContain(`declare$(key, "component", { transfer: true });`);
+ });
+ test("starts a struct's fields and a tuple's items at imported namespaces' defaults, imported as values or types", async () => {
+ const height = { "/src/height.ts": `import type { F32 } from "@adobe/ecs";\nexport type Height = F32;\nexport const Height = { default: 7 } as const;` };
+ expect(await registrations(`import { Quat as Q } from "@adobe/ecs";\n${keys("e")}declare module "@adobe/ecs" { interface Resources { [e]: { type: { readonly q: Q } } } }`))
+ .toEqual([`declare$(e, "resource", { type: {"type":"object","properties":{"q":{"type":"array","items":{"type":"number","precision":1},"minItems":4,"maxItems":4}}}, default: { "q": Q.default } });`]);
+ expect(await registrations(`import type { Quat } from "@adobe/ecs";\n${keys("e")}declare module "@adobe/ecs" { interface Resources { [e]: { type: { readonly q: Quat } } } }`))
+ .toEqual([expect.stringContaining(`default: {"q":[0,0,0,1]}`)]);
+ expect(await registrations(`import type { Height } from "./height.js";\n${keys("e")}declare module "@adobe/ecs" { interface Resources { [e]: { type: readonly [Height, F32] } } }`, height))
+ .toEqual([expect.stringContaining(`default: [7,0]`)]);
+ });
+ test("starts a reduced resource at its operator's identity only when it's one of its type's values", async () => {
+ const reduced = (type: string, operator: string) => registrations(`import type { Schema } from "@adobe/ecs";\n${keys("k")}declare module "@adobe/ecs" { interface Resources { [k]: { type: ${type}; reduce: "${operator}" } } }`);
+ expect(await reduced("Entity", "min")).toEqual([expect.stringContaining("default: Entity.default")]);
+ expect(await reduced(`Schema.Type<{ type: "integer"; minimum: 0 }>`, "min")).toEqual([expect.stringContaining("default: 0")]);
+ expect(await reduced(`Schema.Type<{ type: "integer"; minimum: 0; maximum: 100 }>`, "&")).toEqual([expect.stringContaining("default: 0")]);
+ expect(await reduced("U32", "&")).toEqual([expect.stringContaining("default: 4294967295")]);
+ expect(await reduced(`Schema.Type<{ type: "integer"; minimum: 1; maximum: 9 }>`, "+")).toEqual(expect.stringMatching(/^failure: .*default/));
+ expect(await reduced(`Schema.Type<{ type: "integer"; minimum: 2; maximum: 9 }>`, "*")).toEqual(expect.stringMatching(/^failure: .*default/));
+ });
+ test("starts a reduced struct's fields at their own types' namespaces' defaults before the identity", async () => {
+ expect(await registrations(`${keys("k")}declare module "@adobe/ecs" { interface Resources { [k]: { type: { readonly h: Height; readonly w: F32 }; reduce: "max" } } }\ntype Height = F32;\nconst Height = { default: 7 } as const;`))
+ .toEqual([expect.stringContaining(`default: { "h": Height.default, "w": -Infinity }`)]);
+ });
+ test("fails for an object component whose namespace is imported as a type only, since its start is one object", async () => {
+ expect(await registrations(`import type { Label } from "./label.js";\n${keys("k")}declare module "@adobe/ecs" { interface Components { [k]: { type: Label; thread: "main" } } }`,
+ { "/src/label.ts": `export type Label = { readonly text: string };\nexport const Label = { default: { text: "x" } } as const;` })).toEqual(expect.stringMatching(/^failure: .*import Label as a value/));
+ });
+ test("fails for a namespace's default a cast makes mutable, wherever the cast is", async () => {
+ const named = (namespace: string) => registrations(`${keys("k")}declare module "@adobe/ecs" { interface Resources { [k]: { type: L } } }\ntype L = readonly number[];\n${namespace}`);
+ expect(await named("const L = { default: [] as number[] } as const;")).toEqual(expect.stringMatching(/^failure: L\.default is shared/));
+ expect(await named("const L = { default: [1, 2] as number[] } as const;")).toEqual(expect.stringMatching(/^failure: L\.default is shared/));
+ expect(await named("const L = { default: ([1, 2] as const) as unknown as number[] };")).toEqual(expect.stringMatching(/^failure: L\.default is shared/));
+ expect(await named("const L = { default: [1, 2] as const satisfies readonly number[] };")).toEqual([expect.stringContaining("default: L.default")]);
+ });
+ test("finds a default only where a schema's keywords are, and names the alias holding one", async () => {
+ const typed = (type: string) => registrations(`import type { Schema } from "@adobe/ecs";\n${keys("k")}declare module "@adobe/ecs" { interface Resources { [k]: { type: ${type} } } }`);
+ expect(await typed(`Schema.Type<{ type: "object"; properties: { default: { type: "number" } }; required: ["default"] }>`)).toEqual([expect.stringContaining(`default: {"default":0}`)]);
+ expect(await typed(`Schema.Type<{ const: { default: 1 } }>`)).toEqual([expect.stringContaining(`"const":{"default":1}`)]);
+ expect(await registrations(`import type { Schema } from "@adobe/ecs";\n${keys("k")}declare module "@adobe/ecs" { interface Resources { [k]: { type: Bad } } }\ntype Bad = Schema.Type<{ type: "number"; default: 1 }>;`))
+ .toEqual(expect.stringMatching(/^failure: .*Bad/));
+ });
+ test("checks a namespace's default against its type when it can read it", async () => {
+ expect(await registrations(`${keys("k")}declare module "@adobe/ecs" { interface Resources { [k]: { type: Mode } } }\ntype Mode = "a" | "b";\nconst Mode = { default: "c" } as const;`))
+ .toEqual(expect.stringMatching(/^failure: Mode\.default/));
+ });
+ test("writes in a namespace's -NaN imported as a type only", async () => {
+ expect(await registrations(`import type { Z } from "./z.js";\n${keys("k")}declare module "@adobe/ecs" { interface Resources { [k]: { type: Z } } }`,
+ { "/src/z.ts": `export type Z = number;\nexport const Z = { default: -NaN } as const;` })).toEqual([expect.stringContaining("default: NaN")]);
+ });
+ test("passes over modules without a declaration of the package", async () => {
+ expect(await passed(`declare module "elsewhere" { interface Components { [x]: { type: number } } }\nconst x = Symbol();`)).toBe(null);
+ });
+ test("fails, saying why, for what a declaration can't be", async () => {
+ const block = (body: string) => `declare module "@adobe/ecs" { ${body} }`;
+ expect(await passed(`const key = Symbol.for("key");\n${block(`interface Components { [key]: { type: number } }`)}`)).toContain("top-level `Symbol()` constant");
+ const files = { "/src/keys.ts": `export const elsewhere = Symbol();\nexport const layer = Symbol();` };
+ expect(await passed(`import { elsewhere } from "./keys.js";\n${block(`interface Components { [elsewhere]: { type: number } }`)}`, files)).toContain(`elsewhere is imported from "./keys.js": declare it in the module that declares the key`);
+ expect(await passed(`import type { layer } from "./keys.js";\n${keys("key")}${block(`interface Components { [key]: { type: true; [layer]: true } }`)}`, files)).toContain("layer, imported as a type only");
+ expect(await passed(`${keys("key")}${block(`interface Resources { [key]: { type: 1 | 2 | 4 } }`)}`)).toContain(`a union of literals without 0, false or "" starts at none of them`);
+ expect(await passed(`${keys("key")}${block(`interface Resources { [key]: { type: "all" | "done" } }`)}`)).toContain("declare `default:` one of them");
+ expect(await passed(`const Mode = { a: 1, b: 2 } as const;\n${keys("key")}${block(`interface Resources { [key]: { type: (typeof Mode)[keyof typeof Mode] } }`)}`)).toContain("starts at none of them");
+ expect(await passed(`class Pool {}\n${keys("key")}${block(`interface Resources { [key]: { type: Pool } }`)}`)).toContain("let its type allow null");
+ expect(await passed(`${keys("key")}${block(`interface Resources { [key]: { type: OffscreenCanvas } }`)}`)).toContain("let its type allow null");
+ expect(await passed(`import type { Schema } from "@adobe/ecs";\n${keys("key")}${block(`interface Resources { [key]: { type: Schema.Type<{ type: "integer"; minimum: 1; maximum: 9 }> } }`)}`))
+ .toContain("name its type and give the type a namespace with a `default`");
+ expect(await passed(`${keys("key")}type Level = 1 | 2 | 4;\n${block(`interface Resources { [key]: { type: Level } }`)}`)).toContain("give Level a namespace with a `default`: `const Level = { default: ... }`");
+ expect(await passed(`import type { Schema } from "@adobe/ecs";\n${keys("key")}${block(`interface Resources { [key]: { type: Schema.Type<{ type: "integer"; default: 5 }> } }`)}`)).toContain("a schema holds no default");
+ expect(await passed(`import type { Schema } from "@adobe/ecs";\n${keys("key")}${block(`interface Resources { [key]: { type: Schema.Type<{ type: "integer"; minimum: 1; maximum: 9 }> } }`)}`)).toContain("0 isn't one of its values");
+ expect(await passed(`const start = 1;\n${keys("key")}${block(`interface Resources { [key]: { type: number; default: typeof start } }`)}`)).toContain("default is a literal type, never `typeof` a value");
+ expect(await passed(`import type { Lower } from "./bounds.js";\n${keys("key")}${block(`interface Resources { [key]: { type: Lower } }`)}`, { "/src/bounds.ts": `export type Lower = number;\nconst start: number = Date.now();\nexport const Lower = { default: start } as const;` })).toContain("import Lower as a value");
+ expect(await passed(`${keys("key")}${block(`interface Resources { [key]: { type: U32; default: -1 } }`)}`)).toContain("isn't of its declared type");
+ expect(await passed(`${keys("key")}${block(`interface Components { [key]: { type: HTMLDivElement } }`)}`)).toContain(`thread: "main"`);
+ expect(await passed(`${keys("key")}${block(`interface Components { [key]: { type: Pick<{ a: number }, "a"> } }`)}`)).toContain("type arguments");
+ expect(await passed(`${keys("key")}${block(`interface Components { [key]: { derived: true } }`)}`)).toContain("declares its type");
+ expect(await passed(block(`interface Traits { readonly layer: true }`))).toContain("keyed by a `Symbol()` constant");
+ expect(await passed(`enum Mode { On, Off }\n${keys("key")}${block(`interface Components { [key]: { type: Mode } }`)}`)).toContain("an enum");
+ expect(await passed(`import { Mode } from "./mode.js";\n${keys("key")}${block(`interface Components { [key]: { type: Mode } }`)}`, { "/src/mode.ts": `export enum Mode { On, Off }` })).toContain("an enum");
+ expect(await passed(block(`interface Components { "named": { type: number } }`))).toContain("a component's key is a `Symbol()` constant");
+ expect(await passed(block(`interface Resources { "time": { type: number; default: 0 } }`))).toContain("a resource's key is a `Symbol()` constant");
+ });
+});
+
+describe("registeredReductionsIn", () => {
+ const read = (code: string) => Object.fromEntries(registeredReductionsIn(parseAst(code, { lang: "js" })));
+ test("reads the operators a library's declarations give, however its bundle wrote them", () => {
+ expect(read(`export const total = Symbol(); declare$(total, "resource", { type: {}, reduce: "+" }); function declare$(key, kind, declaration) {}`)).toEqual({ total: "+" });
+ expect(read(`const t = Symbol(), m = Symbol(); d(t, "resource", { reduce: "+" }), d(m, "resource", { reduce: "min" }); export { t as total, m as low };`)).toEqual({ t: "+", m: "min" });
+ expect(read(`const t = Symbol(); void d(t, "resource", { reduce: "+" });`)).toEqual({ t: "+" });
+ expect(read(`const t = Symbol(); (globalThis[Symbol.for("@adobe/ecs/declarations@1")] ??= new Map()).set(t, { kind: "resource", declaration: { reduce: "+" } });`)).toEqual({ t: "+" });
+ expect(read(`const t = Symbol(); (g ??= new Map).set(t, { kind: "resource", declaration: { reduce: "max" } }), e.f(1);`)).toEqual({ t: "max" });
+ });
+ test("ignores components, traits, and keys that aren't symbols", () => {
+ expect(read(`const t = 1; d(t, "resource", { reduce: "+" });`)).toEqual({});
+ expect(read(`const t = Symbol(); d(t, "component", { reduce: "+" }); d(t, "trait");`)).toEqual({});
+ expect(read(`const t = Symbol(); m.set(t, { kind: "component", declaration: { reduce: "+" } });`)).toEqual({});
+ });
+});
diff --git a/packages/ecs/src/implementation/compiler/augmentations.ts b/packages/ecs/src/implementation/compiler/augmentations.ts
new file mode 100644
index 000000000..c96dd7349
--- /dev/null
+++ b/packages/ecs/src/implementation/compiler/augmentations.ts
@@ -0,0 +1,843 @@
+// © 2026 Adobe. MIT License. See /LICENSE for details.
+import { reductionOperators } from '../../interface/registry/reduction.js';
+import { isEntity, isString, isStruct, matchesSchema } from '../../interface/registry/schemas.js';
+import type { Schema } from '../../interface/schema/schema.js';
+import { declarationsName } from '../../interface/registry/ids.js';
+import type { PassResult } from './input-schemas.js';
+import type { Insertion } from './source-map.js';
+import { createConverter, isNotData, isThrown, type Declarator, type Module, type Node, type Statement, type TypedProgram, type TypeHost, type TypeNode } from './type-schemas.js';
+
+export type AugmentationHost = TypeHost;
+
+type Block = Extract;
+type Interface = Extract;
+type Member = Interface["body"]["body"][number];
+type Property = Extract;
+type Value = string | number | boolean | null | readonly Value[] | { readonly [field: string]: Value };
+
+const kinds = { Components: "component", Resources: "resource", Traits: "trait" } as const;
+const isKind = (name: string): name is keyof typeof kinds => name in kinds;
+
+/** The interfaces of each top-level `declare module "@adobe/ecs"` block, with the block. */
+const augmentationsOf = (module: Pick): readonly { readonly block: Block; readonly interfaces: readonly Interface[] }[] =>
+ module.program.body.flatMap(statement => {
+ if (statement.type !== "TSModuleDeclaration" || statement.global !== false || statement.id.type !== "Literal" || statement.id.value !== "@adobe/ecs") return [];
+ const body = statement.body?.type === "TSModuleBlock" ? statement.body.body : [];
+ const interfaces = body.flatMap(inner => {
+ const declared = inner.type === "ExportNamedDeclaration" ? inner.declaration : inner;
+ return declared?.type === "TSInterfaceDeclaration" && isKind(declared.id.name) ? [declared] : [];
+ });
+ return [{ block: statement, interfaces }];
+ });
+
+/**
+ * The operator each resource a module's `declare module "@adobe/ecs"` blocks declare with a `reduce` trait reduces
+ * with, by its key's binding: what a kernel reducing into it folds with.
+ */
+export const reductionsIn = (program: TypedProgram): ReadonlyMap => {
+ const found = new Map();
+ for (const { interfaces } of augmentationsOf({ program })) {
+ for (const declared of interfaces) {
+ if (declared.id.name !== "Resources") continue;
+ for (const member of declared.body.body) {
+ if (member.type !== "TSPropertySignature" || !member.computed || member.key.type !== "Identifier") continue;
+ const declaration = member.typeAnnotation?.typeAnnotation;
+ if (declaration?.type !== "TSTypeLiteral") continue;
+ for (const trait of declaration.members) {
+ if (trait.type !== "TSPropertySignature" || trait.computed || trait.key.type !== "Identifier" || trait.key.name !== "reduce") continue;
+ const value = trait.typeAnnotation?.typeAnnotation;
+ if (value?.type === "TSLiteralType" && value.literal.type === "Literal" && typeof value.literal.value === "string") found.set(member.key.name, value.literal.value);
+ }
+ }
+ }
+ }
+ return found;
+};
+
+type Expression = Extract["expression"];
+type Call = Extract;
+
+/** A call's statement forms as a bundle leaves them: alone, in a sequence, or `void`ed. */
+const callsOf = (expression: Expression): readonly Call[] => {
+ if (expression.type === "CallExpression") return [expression];
+ if (expression.type === "SequenceExpression") return expression.expressions.flatMap(callsOf);
+ if (expression.type === "UnaryExpression" && expression.operator === "void") return callsOf(expression.argument);
+ return [];
+};
+
+/** An object literal's property named `name`, unless computed. */
+const propertyNamed = (object: Extract, name: string): Expression | null => {
+ for (const property of object.properties) {
+ if (property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === name) return property.value;
+ }
+ return null;
+};
+
+/**
+ * The operator each resource a module's generated declarations give, by its key's binding: what a library's build in
+ * library mode leaves of its declarations in its JavaScript. They record `key, "resource", { ..., reduce }` through the
+ * function appended to the module, under any name a bundle gives it, or, inlined, set `{ kind: "resource", declaration }`
+ * on the declarations map; the key is a top-level `Symbol()` constant.
+ */
+export const registeredReductionsIn = (program: TypedProgram): ReadonlyMap => {
+ const symbols = new Set();
+ for (const statement of program.body) {
+ const declaration = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement;
+ if (declaration?.type !== "VariableDeclaration" || declaration.kind !== "const") continue;
+ for (const declarator of declaration.declarations) {
+ const init = declarator.init;
+ if (declarator.id.type === "Identifier" && init?.type === "CallExpression" && init.callee.type === "Identifier" && init.callee.name === "Symbol") symbols.add(declarator.id.name);
+ }
+ }
+ /** The declaration a call records for a resource keyed by a `Symbol()` constant, by either form, with its key. */
+ const recorded = ({ callee, arguments: parameters }: Call): { readonly key: string; readonly declaration: Expression } | null => {
+ const [key, second, third] = parameters;
+ if (key?.type !== "Identifier" || !symbols.has(key.name)) return null;
+ if (second?.type === "Literal" && second.value === "resource" && third?.type === "ObjectExpression") return { key: key.name, declaration: third };
+ const setsEntry = callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" && callee.property.name === "set";
+ if (!setsEntry || second?.type !== "ObjectExpression") return null;
+ const kind = propertyNamed(second, "kind");
+ const declaration = propertyNamed(second, "declaration");
+ return kind?.type === "Literal" && kind.value === "resource" && declaration !== null ? { key: key.name, declaration } : null;
+ };
+ const found = new Map();
+ for (const statement of program.body) {
+ if (statement.type !== "ExpressionStatement") continue;
+ for (const call of callsOf(statement.expression)) {
+ const entry = recorded(call);
+ if (entry === null || entry.declaration.type !== "ObjectExpression") continue;
+ const operator = propertyNamed(entry.declaration, "reduce");
+ if (operator?.type === "Literal" && typeof operator.value === "string") found.set(entry.key, operator.value);
+ }
+ }
+ return found;
+};
+
+type QueryName = Exclude["exprName"], { readonly type: "TSImportType" }>;
+
+type TypeName = Extract["typeName"];
+
+/** A type reference's name and the namespaces it's read through. */
+const typeNameParts = (name: TypeName | Extract["left"]): readonly string[] =>
+ name.type === "Identifier" ? [name.name] : name.type === "TSQualifiedName" ? [...typeNameParts(name.left), name.right.name] : [];
+
+/** A `typeof` query's name and the properties read from it; null for `this`. */
+const qualifiedParts = (name: QueryName | Extract["left"]): readonly string[] | null => {
+ if (name.type === "Identifier") return [name.name];
+ if (name.type !== "TSQualifiedName") return null;
+ const left = qualifiedParts(name.left);
+ return left === null ? null : [...left, name.right.name];
+};
+
+/** The platform's Transferable classes, which `transfer` takes. */
+const transferables: ReadonlySet = new Set([
+ "ArrayBuffer", "ImageBitmap", "OffscreenCanvas", "MessagePort", "VideoFrame", "AudioData",
+ "ReadableStream", "WritableStream", "TransformStream", "RTCDataChannel",
+]);
+
+/** Tests and their helpers in the package, which declare keys as consumers do. */
+const testModule = /\.(test|type-test|fixture|test-helper|cases)\.tsx?$/;
+
+/** Whether a file is one of the package's own modules, whose built-in keys register themselves. */
+const isLibraryModule = (path: string | null, file: string): boolean =>
+ path !== null && (path.startsWith("interface/") || path.startsWith("implementation/")) && !testModule.test(file);
+
+/** Whether a module's top level binds `name`: imported, or declared by a variable, function or class. */
+const bindsAtTopLevel = (program: TypedProgram, name: string): boolean => program.body.some(statement => {
+ if (statement.type === "ImportDeclaration") return statement.specifiers.some(specifier => specifier.local.name === name);
+ const declared = statement.type === "ExportNamedDeclaration" || statement.type === "ExportDefaultDeclaration" ? statement.declaration : statement;
+ if (declared?.type === "VariableDeclaration") return declared.declarations.some(declarator => declarator.id.type === "Identifier" && declarator.id.name === name);
+ return (declared?.type === "FunctionDeclaration" || declared?.type === "ClassDeclaration") && declared.id?.name === name;
+});
+
+/** `base`, or it numbered, so the name appears nowhere in the code. */
+const uniqueName = (code: string, base: string): string => {
+ let name = base;
+ for (let n = 1; new RegExp(`${name.replace("$", "\\$")}(?![\\w$])`).test(code); n++) name = `${base}${n}`;
+ return name;
+};
+
+const allowsNullSchema = (schema: Schema): boolean =>
+ ("type" in schema && schema.type === "null") || ("const" in schema && schema.const === null) || ("anyOf" in schema && schema.anyOf.some(allowsNullSchema));
+
+/** A value as code: JSON, but -0 and the numbers JSON can't write as themselves. */
+const codeOf = (value: unknown): string => {
+ if (Object.is(value, -0)) return "-0";
+ if (typeof value === "number" && !Number.isFinite(value)) return Number.isNaN(value) ? "NaN" : value > 0 ? "Infinity" : "-Infinity";
+ if (Array.isArray(value)) return `[${value.map(codeOf).join(",")}]`;
+ if (typeof value === "object" && value !== null) return `{${Object.entries(value).map(([field, item]) => `${JSON.stringify(field)}:${codeOf(item)}`).join(",")}}`;
+ return JSON.stringify(value);
+};
+
+/** Whether a schema's value is one a literal default can be: what the build reads from literal types. */
+const isValue = (value: unknown): value is Value =>
+ value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean"
+ || (Array.isArray(value) ? value.every(isValue) : typeof value === "object" && Object.values(value).every(isValue));
+
+/** What a value declared without a default starts at, by its type: a value, a fresh `Map` or `Set` per world, or why neither. */
+type Start = { readonly value: Value } | { readonly fresh: "Map" | "Set" } | { readonly none: string };
+
+/** A schema's zero value: 0, false or "", each item's and field's, a union's literal that is one, or a fresh set or map. */
+const zeroOf = (schema: Schema): Start => {
+ if ("const" in schema) return { value: schema.const };
+ if ("anyOf" in schema) {
+ for (const member of schema.anyOf) if ("const" in member && (member.const === 0 || member.const === false || member.const === "")) return { value: member.const };
+ return { none: schema.anyOf.every(member => "const" in member) ? `a union of literals without 0, false or "" starts at none of them: declare \`default:\` one of them` : "a union of types starts at none of them" };
+ }
+ if ("allOf" in schema) return { none: "a value of any type has no zero value" };
+ switch (schema.type) {
+ case "number": case "integer": {
+ const minimum = "minimum" in schema ? schema.minimum ?? -Infinity : -Infinity;
+ const maximum = "maximum" in schema ? schema.maximum ?? Infinity : Infinity;
+ return minimum <= 0 && maximum >= 0 ? { value: 0 } : { none: "0 isn't one of its values" };
+ }
+ case "boolean": return { value: false };
+ case "string": return (schema.minLength ?? 0) === 0 ? { value: "" } : { none: `"" isn't one of its values` };
+ case "null": return { value: null };
+ case "set": return { fresh: "Set" };
+ case "map": return { fresh: "Map" };
+ case "array": {
+ const count = schema.minItems ?? 0;
+ if (count === 0) return { value: [] };
+ if (schema.maxItems !== count) return { none: `an array of ${count} items or more has no zero value` };
+ const item = zeroOf(schema.items);
+ return "value" in item ? { value: Array.from({ length: count }, () => item.value) } : { none: "fresh" in item ? "an item that's a set or a map has no zero value" : item.none };
+ }
+ case "object": {
+ const value: { [field: string]: Value } = {};
+ for (const field of schema.required ?? Object.keys(schema.properties)) {
+ const property = schema.properties[field];
+ const zero = property === undefined ? { none: "it isn't a property" } : zeroOf(property);
+ if (!("value" in zero)) return { none: `its field ${field} starts at nothing: ${"fresh" in zero ? "a set or a map in a struct has no zero value" : zero.none}` };
+ value[field] = zero.value;
+ }
+ return { value };
+ }
+ }
+};
+
+/**
+ * What a resource reduced with `operator` starts at when it declares no start: the operator's identity, leaf by leaf,
+ * as a fold of no contributions leaves it; null for a schema of other leaves. Used only when it's one of the
+ * schema's values.
+ */
+const identityOf = (schema: Schema, operator: unknown): Value | null => {
+ if ("type" in schema && schema.type === "boolean") return operator === "&";
+ if ("type" in schema && (schema.type === "number" || schema.type === "integer")) {
+ const minimum = "minimum" in schema ? schema.minimum : undefined;
+ const maximum = "maximum" in schema ? schema.maximum : undefined;
+ switch (operator) {
+ case "+": case "|": case "^": return 0;
+ case "*": return 1;
+ // Every bit of its column set: u32's all ones, else -1.
+ case "&": return minimum !== undefined && minimum >= 0 && maximum !== undefined && maximum <= 0xFFFFFFFF ? 0xFFFFFFFF : -1;
+ case "min": return maximum ?? Infinity;
+ case "max": return minimum ?? -Infinity;
+ default: return null;
+ }
+ }
+ if ("type" in schema && schema.type === "array" && schema.minItems !== undefined && schema.minItems === schema.maxItems) {
+ const item = identityOf(schema.items, operator);
+ return item === null ? null : Array.from({ length: schema.minItems }, () => item);
+ }
+ if ("type" in schema && schema.type === "object") {
+ const value: { [field: string]: Value } = {};
+ for (const [field, property] of Object.entries(schema.properties)) {
+ const leaf = identityOf(property, operator);
+ if (leaf === null) return null;
+ value[field] = leaf;
+ }
+ return value;
+ }
+ return null;
+};
+
+/**
+ * Registers what each `declare module "@adobe/ecs"` block declares, so consumers never write registration: each
+ * component and resource of `Components` and `Resources`, with the schema of its `type`, its `default` and its other
+ * traits as declared, and each trait of `Traits` keyed by a symbol. A key is a top-level `Symbol()` constant of this
+ * module, so importing a key registers it: its registration comes right after it, after any binding its declaration
+ * names, and after a trait of this module it declares, wherever the block is. A literal default and the built-in
+ * traits' values are checked as far as literals and types show. A resource or derived component without a literal
+ * default starts at its type's: its namespaced schema's, null, a zero value, or a fresh `Map` or `Set` per world.
+ * Returns null when the module declares nothing.
+ */
+export const augmentationInsertions = async (module: Module, host: AugmentationHost): Promise => {
+ const { code, file } = module;
+ if (!code.includes("@adobe/ecs") || !code.includes("declare module") || isLibraryModule(host.packagePath(file), file)) return null;
+ const augmentations = augmentationsOf(module);
+ if (augmentations.length === 0) return null;
+ const converter = createConverter(host, file);
+ const { fail } = converter;
+ const insertions: Insertion[] = [];
+ // A name the module doesn't use for the function appended to record its declarations.
+ const record = uniqueName(code, "declare$");
+
+ /**
+ * Where a local binding has its value, which a registration naming it comes after: the end of the top-level
+ * statement declaring it, and 0 for a function, hoisted with its value, or anything imported.
+ */
+ const endOfLocal = (name: string): number => {
+ for (const statement of module.program.body) {
+ const declared = statement.type === "ExportNamedDeclaration" || statement.type === "ExportDefaultDeclaration" ? statement.declaration : statement;
+ if (declared?.type === "VariableDeclaration" && declared.declarations.some(declarator => declarator.id.type === "Identifier" && declarator.id.name === name)) return statement.end;
+ if ((declared?.type === "ClassDeclaration" || declared?.type === "TSEnumDeclaration") && declared.id?.name === name) return statement.end;
+ }
+ return 0;
+ };
+
+ /** Whether an initializer is a call of the global `Symbol`. */
+ const isSymbolCall = (init: Declarator["init"]): boolean => {
+ let expression = init;
+ while (expression !== null && (expression.type === "TSAsExpression" || expression.type === "TSSatisfiesExpression" || expression.type === "ParenthesizedExpression")) expression = expression.expression;
+ return expression?.type === "CallExpression" && expression.callee.type === "Identifier" && expression.callee.name === "Symbol";
+ };
+
+ /**
+ * Fails unless a key a block declares is a top-level `Symbol()` constant of this module, so importing the key
+ * registers it.
+ */
+ const requireOwnKey = (name: string, at: Node, what: string): void => {
+ const imported = module.imports.get(name);
+ if (imported !== undefined) fail(module, at, `${what} ${name} is imported from "${imported.source}": declare it in the module that declares the key`);
+ if (!isSymbolCall(module.constants.get(name)?.init ?? null)) fail(module, at, `${what} ${name} is a top-level \`Symbol()\` constant of this module`);
+ };
+
+ /** Fails unless a trait key a declaration names is a `Symbol()` constant, here or imported as a value. */
+ const requireTraitKey = async (name: string, at: Node): Promise => {
+ const imported = module.imports.get(name);
+ if (imported !== undefined && !imported.value) fail(module, at, `a trait's key names ${name}, imported as a type only: import it as a value, which the registration passes`);
+ const found = await converter.constantNamed([name], module);
+ if (!isSymbolCall(found?.declaration.init ?? null)) fail(module, at, `a trait's key ${name} is a top-level \`Symbol()\` constant, of this module or one it imports`);
+ };
+
+ /** Whether a type, as written, allows `null`, through the aliases it names. */
+ const allowsNullType = async (type: TypeNode, at: Module, seen: Set