Symbols {totalSymbols}
Classes {classCount}
diff --git a/site/src/components/pages/BenchFullResultsPage.astro b/site/src/components/pages/BenchFullResultsPage.astro
index f8bad594d..1aa1aa4cd 100644
--- a/site/src/components/pages/BenchFullResultsPage.astro
+++ b/site/src/components/pages/BenchFullResultsPage.astro
@@ -33,7 +33,7 @@ const base = import.meta.env.BASE_URL;
All measurements
- The tables behind the benchmark overview: every measured load, with its spread, its p95 and the rows the harness left out.
+ Every measured load, run spread, pooled per-run p95, and documented exclusion. Read each profile with its own machine, browser, and engine revision; differences across profiles do not isolate one cause.
Back to the benchmark overview
diff --git a/site/src/components/pages/BenchmarksPage.astro b/site/src/components/pages/BenchmarksPage.astro
index df3463dbe..8628ece28 100644
--- a/site/src/components/pages/BenchmarksPage.astro
+++ b/site/src/components/pages/BenchmarksPage.astro
@@ -86,6 +86,13 @@ const leading = browsers[0];
Rendering and physics, compared.
+
+ What these measurements cover
+ Rendering records the declared CPU-side frame region; physics records one simulation step. Neither is GPU execution time, display latency, memory use, or a complete game loop. Compare equal scenario loads within the selected profile, not ratios across different machines.
+ Equivalent supported workloads are compared; an unsupported arm is absent rather than assigned zero. Published values pool independent runs. A pooled p95 is the median of the run p95 values, not a percentile of concatenated samples. Reporting bands are policy, not statistical confidence intervals.
+ The methodology defines the measurement regions and fairness rules. The reproduction instructions describe acquisition and provenance. Hashes detect content inconsistency; they do not independently attest to the hardware or execution.
+
+
{leading === undefined ? (
No measurements are published yet. The reference measurement is {REPRODUCTION_RUNS} separate runs on one machine after a release is tagged,
@@ -141,7 +148,7 @@ const leading = browsers[0];
* figure had already been read as a warning.
*/}
- Orange marks a time over the 16.7 ms frame budget at 60 fps. The measurement is valid. The scene does not fit in one frame.
+ Orange marks a time over the 16.7 ms frame budget at 60 fps. The measured region alone exceeds that interval; this is not a whole-application frame-rate measurement.
{browsers.map((entry, index) => (
@@ -150,7 +157,7 @@ const leading = browsers[0];
Rendering
-
CPU time per frame · Lower is better
+
CPU-side frame work · milliseconds · Lower is better
{entry.rendering.length > 1 && (
@@ -198,7 +205,7 @@ const leading = browsers[0];
Physics
-
CPU time per step · Lower is better
+
CPU time per step · milliseconds · Lower is better
@@ -240,7 +247,7 @@ const leading = browsers[0];
*/}
A library appears in a scenario where its own API covers it and its arm does the same work as the others. One that sits a scenario out, or that would
- render something else in it, is left out of that card rather than compared on unequal terms. Nothing measured is discarded. All measurements lists every arm and every load.
diff --git a/site/src/components/pages/GuideChapterPage.astro b/site/src/components/pages/GuideChapterPage.astro
index be21f7a1d..8c53e6ce6 100644
--- a/site/src/components/pages/GuideChapterPage.astro
+++ b/site/src/components/pages/GuideChapterPage.astro
@@ -87,7 +87,7 @@ const relatedApi = chapterMeta.apiLinks.map(slug => {
const getFirstChapterHref = (part: (typeof GUIDE_PARTS)[number]) => {
const firstChapter = part.chapters[0];
return firstChapter
- ? `${import.meta.env.BASE_URL}${locale}/guide/${part.slug}/${firstChapter.slug}/`
+ ? `${import.meta.env.BASE_URL}${locale}/guide/${firstChapter.path}/`
: `${import.meta.env.BASE_URL}${locale}/guide/${part.slug}/`;
};
const sidebarParts = GUIDE_PARTS.map(part => ({
@@ -100,7 +100,7 @@ const sidebarParts = GUIDE_PARTS.map(part => ({
chapters: part.chapters.map(chapter => ({
slug: chapter.slug,
title: titleByPath(chapter.path),
- href: `${import.meta.env.BASE_URL}${locale}/guide/${part.slug}/${chapter.slug}/`,
+ href: `${import.meta.env.BASE_URL}${locale}/guide/${chapter.path}/`,
})),
}));
---
diff --git a/site/src/components/pages/GuideChapterRedirect.astro b/site/src/components/pages/GuideChapterRedirect.astro
new file mode 100644
index 000000000..6bb294c0c
--- /dev/null
+++ b/site/src/components/pages/GuideChapterRedirect.astro
@@ -0,0 +1,44 @@
+---
+import { getCollection } from 'astro:content';
+import type { CollectionEntry } from 'astro:content';
+import { GUIDE_CHAPTER_BY_PATH } from '../../lib/guide-structure';
+
+interface Props {
+ locale: 'en' | 'de';
+ chapterPath: string;
+}
+
+const { locale, chapterPath } = Astro.props;
+const chapter = GUIDE_CHAPTER_BY_PATH.get(chapterPath);
+
+if (!chapter) {
+ throw new Error(`Unknown guide chapter: ${chapterPath}`);
+}
+
+const guideEntries = await getCollection('guide');
+const chapterTitle =
+ guideEntries.find((entry: CollectionEntry<'guide'>) => entry.id.replace(/\.(md|mdx)$/, '') === chapter.path)?.data.title ?? chapter.slug;
+
+const targetHref = `${import.meta.env.BASE_URL}${locale}/guide/${chapter.path}/`;
+const redirectLabel = locale === 'de' ? 'Weiterleitung zu' : 'Redirecting to';
+---
+
+
+
+
+
+
+
+
+
{chapterTitle} | ExoJS Guide
+
+
+
+
+
{redirectLabel} {chapterTitle}...
+
+
diff --git a/site/src/components/pages/GuideIndexPage.astro b/site/src/components/pages/GuideIndexPage.astro
index d355872ef..21eec3576 100644
--- a/site/src/components/pages/GuideIndexPage.astro
+++ b/site/src/components/pages/GuideIndexPage.astro
@@ -29,7 +29,7 @@ const topics = GUIDE_TOPICS;
const getFirstChapterHref = (part: (typeof GUIDE_PARTS)[number]) => {
const firstChapter = part.chapters[0];
- return firstChapter ? guideHref(`${part.slug}/${firstChapter.slug}`) : `${base}${locale}/guide/${part.slug}/`;
+ return firstChapter ? guideHref(firstChapter.path) : `${base}${locale}/guide/${part.slug}/`;
};
const sidebarParts = GUIDE_PARTS.map(part => ({
slug: part.slug,
@@ -77,15 +77,15 @@ const sidebarParts = GUIDE_PARTS.map(part => ({
npm create exo-app@latest my-game
Recommended learning path
- Follow these in order to go from an empty folder to a deployable game.
+ Follow the core path to understand the runtime, then choose a gameplay or integration topic. Advanced chapters are task references, not prerequisites for every project.
{learningPath.map((step, index) => (
-
@@ -124,12 +124,12 @@ const sidebarParts = GUIDE_PARTS.map(part => ({
→
Playground
-
Run and edit every example live in the browser.
+
Run and edit the curated demonstrations; individual examples state their capabilities.
→
API reference
-
Documents every class, method, and option.
+
States the generated public contracts, parameters, units, and ownership rules.
diff --git a/site/src/components/pages/GuidePartRedirect.astro b/site/src/components/pages/GuidePartRedirect.astro
index c53beee87..05eae34c6 100644
--- a/site/src/components/pages/GuidePartRedirect.astro
+++ b/site/src/components/pages/GuidePartRedirect.astro
@@ -1,7 +1,6 @@
---
-import { getCollection } from 'astro:content';
-import type { CollectionEntry } from 'astro:content';
import { GUIDE_PARTS } from '../../lib/guide-structure';
+import GuideChapterRedirect from './GuideChapterRedirect.astro';
interface Props {
locale: 'en' | 'de';
@@ -19,30 +18,6 @@ const firstChapter = part.chapters[0];
if (!firstChapter) {
throw new Error(`Guide part has no chapters: ${partSlug}`);
}
-
-const guideEntries = await getCollection('guide');
-const firstChapterTitle =
- guideEntries.find((entry: CollectionEntry<'guide'>) => entry.id.replace(/\.(md|mdx)$/, '') === firstChapter.path)?.data
- .title ?? firstChapter.slug;
-
-const targetHref = `${import.meta.env.BASE_URL}${locale}/guide/${part.slug}/${firstChapter.slug}/`;
-const redirectLabel = locale === 'de' ? 'Weiterleitung zu' : 'Redirecting to';
---
-
-
-
-
-
-
{part.title} | ExoJS Guide
-
-
-
-
-
{redirectLabel} {firstChapterTitle}...
-
-
+
diff --git a/site/src/components/pages/HomePage.astro b/site/src/components/pages/HomePage.astro
index 6ae6763d8..7dde15cc2 100644
--- a/site/src/components/pages/HomePage.astro
+++ b/site/src/components/pages/HomePage.astro
@@ -1,5 +1,6 @@
---
import AppShell from '../../layouts/AppShell.astro';
+import { extractSnippetRegion } from '../../lib/source-snippets';
import { Code } from 'astro:components';
import EnglishFallbackNotice from '../EnglishFallbackNotice.astro';
import ExampleThumb from '../../components/ExampleThumb.astro';
@@ -21,66 +22,19 @@ const localeBase = `${import.meta.env.BASE_URL}${locale}/`;
const chapterCount = GUIDE_PARTS.reduce((sum, part) => sum + part.chapters.length, 0);
const demoCount = getAllExamples().length;
-// Live hero example - an ambient lighting showcase that runs everywhere
+// Live hero example - an ambient lighting showcase that uses the supported backend path
// (WebGL2, no input or audio gesture needed) so the landing shows the real
// engine rendering in the page, not a video.
const heroExample = getExamplesForChapter('lighting').find(entry => entry.slug === 'many-lights');
const heroExampleTitle = heroExample?.title ?? 'Normal-Mapped Lighting';
const heroExampleSource = getExampleExecutionSource('lighting', 'many-lights');
-const heroSnippet = `import { Application, Color, Scene, Sprite } from '@codexo/exojs';
-import { Lighting, LitMaterial, Normals, PointLight } from '@codexo/exojs-lighting';
-
-const lighting = new Lighting();
-const lights = Array.from({ length: 24 }, (_, i) => {
- const color = Color.fromCss(\`hsl(\${i * 15} 80% 60%)\`);
- return new PointLight({ radius: 190, color });
-});
-
-class LitScene extends Scene {
- async load(loader) {
- const stone = await loader.load('stone.png');
- const normalMap = await loader.load('stone-normal.png');
- const material = new LitMaterial({ lighting, normals: Normals.map(normalMap) });
-
- for (let i = 0; i < 112; i++) {
- const tile = new Sprite(stone);
- tile.setPosition((i % 14) * 96, Math.floor(i / 14) * 96);
- tile.material = material;
- this.root.addChild(tile);
- }
- }
-
- init() {
- this.systems.add(lighting);
- lights.forEach(light => lighting.add(light));
- }
-
- update() {
- const t = this.app.activeSeconds;
- lights.forEach((light, i) => {
- const angle = t * (0.25 + i * 0.08) + i;
- light.setPosition(640 + Math.cos(angle) * 420, 360 + Math.sin(angle * 1.37) * 260);
- });
- }
-
- draw(context) {
- context.render(this.root);
- }
-}
-
-new Application({ canvas: { width: 1280, height: 720 } }).start(new LitScene());`;
-
-const quickstartSnippet = `import { Application, Scene } from '@codexo/exojs';
-
-class HelloScene extends Scene {
- draw(context) {
- context.render(this.root);
- }
-}
+const heroSnippet = extractSnippetRegion('examples/guides/lighting/basic-lightmap.ts', 'basic-lightmap');
-const app = new Application({ canvas: { width: 800, height: 600 } });
-app.start(new HelloScene());`;
+const quickstartSnippet = `npm create exo-app@latest my-game -- --template minimal
+cd my-game
+npm install
+npm run dev`;
const examples = [
{
@@ -145,9 +99,7 @@ const examples = [
A TypeScript-first
2D runtime for
arcade games
-
- ExoJS gives you scenes, sprites, graphics, input, audio, effects, assets, and rendering primitives — without turning your project into a full game-engine workflow.
-
+
Build a game, visualization, or interactive canvas with scenes, rendering, input, audio, and explicit resource lifetimes. Keep the surrounding web application in the tools you already use.
- game.ts
+ basic-lightmap.ts
scenelighting
@@ -223,15 +175,12 @@ const examples = [
FX
Filters & particles
- Post-processing, bloom, blur, particles — composable and GPU-accelerated.
+ Compose filters and optional particles; simulation uses a CPU or eligible WebGPU compute path.
-
- Measured against Pixi, Phaser, Excalibur, matter.js, planck and Rapier on one reference machine — roughly 2x ahead of Pixi on WebGPU filter
- chains, and about 7x behind it on masked clipping, both published with the counters behind them.
-
+
Inspect named rendering and physics workloads, the machine and browser behind each result, and the measurement limits. The benchmark pages publish the evidence without an overall engine score.
See the benchmarks
@@ -265,14 +214,14 @@ const examples = [
-
Install once, use anywhere.
-
ExoJS ships as plain ES modules. Drop it into Vite, esbuild, or whatever you already use.
+
Start small. Add the systems you need.
+
Create a Vite and TypeScript starter, or add the ESM package to an existing application. The Guide explains both paths.
Open Getting Started
diff --git a/site/src/content/api/lighting.json b/site/src/content/api/lighting.json
index e45b76b83..8fef2f67f 100644
--- a/site/src/content/api/lighting.json
+++ b/site/src/content/api/lighting.json
@@ -1,6 +1,6 @@
{
"title": "Lighting",
- "description": "What a lighting system does with lights, materials and occluders, whichever renderer turns them into pixels. ```ts const lighting = new LightmapLighting(app, { ambient: new Color(11, 16, 32) }); scene.systems.add(lighting); lighting.add(player.addChild(new PointLight({ radius: 260 }))); lighting.occludeFrom(new PhysicsOccluder(world)); ``` Construct one of ForwardLighting, LightmapLighting or RadianceLighting. They are alternatives rather than layers, and a frame is shaded by exactly one of them. This class is what they share: the registries, the collection of occluders, and the update and destroy contracts. It links no renderer of its own, which is what keeps a project using one of them from carrying the others. # What it owns The renderer and its GPU resources. Lights are scene nodes owned by the tree they hang in - registering one does not transfer ownership, and destroying a registered light unregisters it. Occluder sources, filters passed as `post`, and the host are the caller's too. # Ordering Register it with the registry that ticks AFTER the code moving the lights, so the frame it shades is the frame that was drawn. `app.systems` runs its update phase before the active scene's, so a system registered there sees lights the scene has not moved yet; `scene.systems` is usually what you want.",
+ "description": "What a lighting system does with lights, materials and occluders, whichever renderer turns them into pixels. ```ts // In Scene.init(), where the scene's application is attached: const lighting = new LightmapLighting(this.app, { ambient: new Color(11, 16, 32) }); this.systems.add(lighting); lighting.add(player.addChild(new PointLight({ radius: 260 }))); lighting.occludeFrom(new PhysicsOccluder(world)); ``` Construct one of ForwardLighting, LightmapLighting or RadianceLighting. They are alternative lighting models rather than layers or quality levels, and a frame is shaded by exactly one of them: forward lighting shades materials as they draw, lightmap lighting shades the composed frame and can use a registered normal prepass, and radiance lighting samples a propagated light field. This class is what they share: the registries, the collection of occluders, and the update and destroy contracts. It links no renderer of its own, which is what keeps a project using one of them from carrying the others. # What it owns The renderer and its GPU resources. Lights are scene nodes owned by the tree they hang in - registering one does not transfer ownership, and destroying a registered light unregisters it. Occluder and normal sources, filters passed as `post`, and the host are the caller's too. # Ordering Register it with the registry that ticks AFTER the code moving the lights, so the frame it shades is the frame that was drawn. `app.systems` runs its update phase before the active scene's, so a system registered there sees lights the scene has not moved yet; `scene.systems` is usually what you want.",
"symbol": "Lighting",
"kind": "class",
"subsystem": "lighting",
@@ -20,11 +20,11 @@
"members": [],
"paragraphs": [
"What a lighting system does with lights, materials and occluders, whichever renderer turns them into pixels.",
- "```ts const lighting = new LightmapLighting(app, { ambient: new Color(11, 16, 32) });",
- "scene.systems.add(lighting); lighting.add(player.addChild(new PointLight({ radius: 260 }))); lighting.occludeFrom(new PhysicsOccluder(world)); ```",
- "Construct one of ForwardLighting, LightmapLighting or RadianceLighting. They are alternatives rather than layers, and a frame is shaded by exactly one of them. This class is what they share: the registries, the collection of occluders, and the update and destroy contracts. It links no renderer of its own, which is what keeps a project using one of them from carrying the others.",
+ "```ts // In Scene.init(), where the scene's application is attached: const lighting = new LightmapLighting(this.app, { ambient: new Color(11, 16, 32) });",
+ "this.systems.add(lighting); lighting.add(player.addChild(new PointLight({ radius: 260 }))); lighting.occludeFrom(new PhysicsOccluder(world)); ```",
+ "Construct one of ForwardLighting, LightmapLighting or RadianceLighting. They are alternative lighting models rather than layers or quality levels, and a frame is shaded by exactly one of them: forward lighting shades materials as they draw, lightmap lighting shades the composed frame and can use a registered normal prepass, and radiance lighting samples a propagated light field. This class is what they share: the registries, the collection of occluders, and the update and destroy contracts. It links no renderer of its own, which is what keeps a project using one of them from carrying the others.",
"# What it owns",
- "The renderer and its GPU resources. Lights are scene nodes owned by the tree they hang in - registering one does not transfer ownership, and destroying a registered light unregisters it. Occluder sources, filters passed as `post`, and the host are the caller's too.",
+ "The renderer and its GPU resources. Lights are scene nodes owned by the tree they hang in - registering one does not transfer ownership, and destroying a registered light unregisters it. Occluder and normal sources, filters passed as `post`, and the host are the caller's too.",
"# Ordering",
"Register it with the registry that ticks AFTER the code moving the lights, so the frame it shades is the frame that was drawn. `app.systems` runs its update phase before the active scene's, so a system registered there sees lights the scene has not moved yet; `scene.systems` is usually what you want."
],
diff --git a/site/src/content/api/loader-scope.json b/site/src/content/api/loader-scope.json
index 867c2289c..2882118e5 100644
--- a/site/src/content/api/loader-scope.json
+++ b/site/src/content/api/loader-scope.json
@@ -1,6 +1,6 @@
{
"title": "LoaderScope",
- "description": "An owner of asset claims with an explicit lifetime. Assets acquired through a scope stay resident for as long as that scope holds them, and are freed when it releases them - but only if no other scope still holds the same asset. Several scopes can own one asset independently: they share a single fetch and a single resident payload, and one scope releasing never invalidates another. Create a scope with Loader.createScope whenever an asset's lifetime is shorter than the application's - a level, a streamed chunk, a UI panel, a prefetch. Assets acquired directly on the Loader are held for the application's lifetime instead and are freed only when the loader is destroyed. A scope describes a lifetime, never a set of assets: what to acquire comes from an Assets catalog or an Asset descriptor passed to get / load, and typed access stays on that catalog. Scopes nest: createScope makes a child whose claims are independent but whose lifetime cannot outlive its parent's.",
+ "description": "An owner of asset claims with an explicit lifetime. Assets acquired through a scope stay resident for as long as that scope holds them, and are freed when it releases them - but only if no other scope still holds the same asset. Several scopes can own one asset independently: they share a single fetch and a single resident payload, and one scope releasing never invalidates another. Use the scene's scope for scene assets, and a child scope from createScope for anything shorter-lived - a level, a streamed chunk, a preview, a prefetch. Assets acquired directly on the Loader are held for the application's lifetime instead and are freed only when the loader is destroyed. A scope describes a lifetime, never a set of assets: what to acquire comes from an Assets catalog or an Asset descriptor passed to get / load, and typed access stays on that catalog.",
"symbol": "LoaderScope",
"kind": "class",
"subsystem": "assets",
@@ -21,9 +21,8 @@
"paragraphs": [
"An owner of asset claims with an explicit lifetime.",
"Assets acquired through a scope stay resident for as long as that scope holds them, and are freed when it releases them - but only if no other scope still holds the same asset. Several scopes can own one asset independently: they share a single fetch and a single resident payload, and one scope releasing never invalidates another.",
- "Create a scope with Loader.createScope whenever an asset's lifetime is shorter than the application's - a level, a streamed chunk, a UI panel, a prefetch. Assets acquired directly on the Loader are held for the application's lifetime instead and are freed only when the loader is destroyed.",
- "A scope describes a lifetime, never a set of assets: what to acquire comes from an Assets catalog or an Asset descriptor passed to get / load, and typed access stays on that catalog.",
- "Scopes nest: createScope makes a child whose claims are independent but whose lifetime cannot outlive its parent's."
+ "Use the scene's scope for scene assets, and a child scope from createScope for anything shorter-lived - a level, a streamed chunk, a preview, a prefetch. Assets acquired directly on the Loader are held for the application's lifetime instead and are freed only when the loader is destroyed.",
+ "A scope describes a lifetime, never a set of assets: what to acquire comes from an Assets catalog or an Asset descriptor passed to get / load, and typed access stays on that catalog."
],
"importLine": "import { LoaderScope } from '@codexo/exojs'",
"sourceLink": null
@@ -81,7 +80,7 @@
}
],
"returnType": "LoaderScope",
- "description": "Creates a child scope: an independent claim owner that cannot outlive this one. The child claims, shares and releases assets exactly like any other scope - one fetch, one resident payload, one claim per owner - and holding the same asset as its parent means two claims, not one. Destroying the child frees only the child's claims; destroying the parent destroys every child it still has first, recursively, so a scene or level teardown reaches the scopes created underneath it without extra bookkeeping. The hierarchy is a lifetime hierarchy only. It never affects asset identity, ownership or what a release frees."
+ "description": "Creates a child scope: an independent claim owner that cannot outlive this one. The child claims, shares and releases assets exactly like any other scope, and holding the same asset as its parent means two claims, not one. Destroying the child frees only the child's claims; destroying the parent destroys every child it still has first, recursively, so a scene or level teardown reaches the scopes created underneath it. The hierarchy is a lifetime hierarchy only. It never affects asset identity, ownership or what a release frees."
},
{
"name": "destroy",
@@ -110,7 +109,7 @@
],
"params": [],
"returnType": "void",
- "description": "Releases every claim this scope still holds and destroys any child scope it still has. Assets another scope also holds stay resident, and destroying an already-destroyed scope is a no-op. Acquiring through the scope afterwards - get, load, loadContainer - throws, because the claim it would register has no owner left to release it."
+ "description": "Releases every claim this scope still holds and destroys any child scope it still has. Assets another scope also holds stay resident, and destroying an already-destroyed scope is a no-op. Acquiring through the scope afterwards - get, load, loadContainer, createScope - throws, because what it would register has no owner left to release it."
},
{
"name": "get",
@@ -254,7 +253,7 @@
}
],
"returnType": "LeafForPath",
- "description": ""
+ "description": "Claims an asset for this scope and returns synchronously: a handle that fills in place, a value reference, or a catalog's leaves. Starts loading if the asset is not resident; it is not a passive cache lookup. Await load when the finished value is required. A bare path reuses its source-keyed handle, while each new descriptor can produce a distinct leaf sharing the same resident payload."
},
{
"name": "get",
@@ -345,7 +344,7 @@
}
],
"returnType": "CatalogValueLeaf",
- "description": ""
+ "description": "Claims an asset for this scope and returns synchronously: a handle that fills in place, a value reference, or a catalog's leaves. Starts loading if the asset is not resident; it is not a passive cache lookup. Await load when the finished value is required. A bare path reuses its source-keyed handle, while each new descriptor can produce a distinct leaf sharing the same resident payload."
},
{
"name": "get",
@@ -416,7 +415,7 @@
}
],
"returnType": "CatalogResourceLeaf",
- "description": ""
+ "description": "Claims an asset for this scope and returns synchronously: a handle that fills in place, a value reference, or a catalog's leaves. Starts loading if the asset is not resident; it is not a passive cache lookup. Await load when the finished value is required. A bare path reuses its source-keyed handle, while each new descriptor can produce a distinct leaf sharing the same resident payload."
},
{
"name": "get",
@@ -487,7 +486,7 @@
}
],
"returnType": "InferAssetsProperties",
- "description": ""
+ "description": "Claims an asset for this scope and returns synchronously: a handle that fills in place, a value reference, or a catalog's leaves. Starts loading if the asset is not resident; it is not a passive cache lookup. Await load when the finished value is required. A bare path reuses its source-keyed handle, while each new descriptor can produce a distinct leaf sharing the same resident payload."
},
{
"name": "get",
@@ -558,7 +557,7 @@
}
],
"returnType": "CatalogResourceLeaf",
- "description": ""
+ "description": "Claims an asset for this scope and returns synchronously: a handle that fills in place, a value reference, or a catalog's leaves. Starts loading if the asset is not resident; it is not a passive cache lookup. Await load when the finished value is required. A bare path reuses its source-keyed handle, while each new descriptor can produce a distinct leaf sharing the same resident payload."
},
{
"name": "load",
@@ -629,7 +628,7 @@
}
],
"returnType": "LoadingQueue",
- "description": ""
+ "description": "Claims assets for this scope and returns an awaitable loading queue. A catalog resolves to a new map of finished values, while the catalog's own leaves also become ready in place; the returned map is not the catalog object. Fetch and decode failures reject the queue, and the claim still belongs to this scope. Background priority is available on the catalog and catalog-leaf overloads."
},
{
"name": "load",
@@ -737,7 +736,7 @@
}
],
"returnType": "LoadingQueue>",
- "description": ""
+ "description": "Claims assets for this scope and returns an awaitable loading queue. A catalog resolves to a new map of finished values, while the catalog's own leaves also become ready in place; the returned map is not the catalog object. Fetch and decode failures reject the queue, and the claim still belongs to this scope. Background priority is available on the catalog and catalog-leaf overloads."
},
{
"name": "load",
@@ -833,7 +832,7 @@
}
],
"returnType": "LoadingQueue",
- "description": ""
+ "description": "Claims assets for this scope and returns an awaitable loading queue. A catalog resolves to a new map of finished values, while the catalog's own leaves also become ready in place; the returned map is not the catalog object. Fetch and decode failures reject the queue, and the claim still belongs to this scope. Background priority is available on the catalog and catalog-leaf overloads."
},
{
"name": "load",
@@ -929,7 +928,7 @@
}
],
"returnType": "LoadingQueue",
- "description": ""
+ "description": "Claims assets for this scope and returns an awaitable loading queue. A catalog resolves to a new map of finished values, while the catalog's own leaves also become ready in place; the returned map is not the catalog object. Fetch and decode failures reject the queue, and the claim still belongs to this scope. Background priority is available on the catalog and catalog-leaf overloads."
},
{
"name": "load",
@@ -1072,7 +1071,7 @@
}
],
"returnType": "LoadingQueue>>",
- "description": ""
+ "description": "Claims assets for this scope and returns an awaitable loading queue. A catalog resolves to a new map of finished values, while the catalog's own leaves also become ready in place; the returned map is not the catalog object. Fetch and decode failures reject the queue, and the claim still belongs to this scope. Background priority is available on the catalog and catalog-leaf overloads."
},
{
"name": "loadContainer",
@@ -1519,7 +1518,7 @@
],
"params": [],
"returnType": null,
- "description": "Fired once every asset in this scope's batch has settled."
+ "description": "Fires after every foreground item in this scope's current batch has settled, including failures. It does not imply that every asset succeeded; await the returned loading queue to observe success or failure."
},
{
"name": "onLoadError",
diff --git a/site/src/content/api/particle-system.json b/site/src/content/api/particle-system.json
index 3a5fbdf66..996dc41ee 100644
--- a/site/src/content/api/particle-system.json
+++ b/site/src/content/api/particle-system.json
@@ -1,6 +1,6 @@
{
"title": "ParticleSystem",
- "description": "The central coordinator of the particle pipeline. `ParticleSystem` is a Drawable that owns: - **Particle storage** - one channel per attribute (position, velocity, scale, rotation, color, timing, ...), sized to a fixed capacity at construction. Modules and render modes address it by name through a ParticleBatch; user code brings particles into existence with emit. - **Spawn modules** - fill freshly emitted particles. - **Update modules** - mutate the live range each frame (forces, color blends, scale curves, drag, ...). Built-in modules ship both CPU and WGSL implementations; custom modules can opt into GPU acceleration by implementing `wgsl()`. - **Death modules** - fire once per dying particle, before its slot is recycled (sub-emitters, event hooks). **Auto-routing CPU vs GPU:** at first update, the system checks: if a `WebGpuBackend` was supplied AND every registered update module has `wgsl()` AND the render mode is GPU-eligible, the GPU path engages - a composite compute pipeline runs integration plus all module bodies in one dispatch and writes directly into the renderer's instance buffer (no CPU readback). Otherwise the CPU path runs the existing per-module `apply()` loops. **Per-frame order in update (CPU mode):** 1. Run every spawn module. 2. Integrate position from velocity, rotation from rotationSpeed, advance `elapsed`. 3. Run every update module on the live range. 4. Compact: scan `[0, liveCount)` forward, fire death modules on expired slots, copy survivors down. `liveCount` shrinks to the survivor count. **Per-frame order in update (GPU mode):** 1. Run every spawn module (CPU writes initial values into the spawn slot). 2. Detect expiries on CPU (via `elapsed >= lifetime`); fire death modules; set `lifetime[slot] = -1` sentinel + clear `alive[slot]` so the GPU shader skips them. **No compaction** - slots are recycled on next spawn. 3. Dispatch the composite compute pipeline. Integration + update modules + pack-instances run in one pass; the instance buffer is written directly. CPU SoA stays as-is for spawn writes. **Coordinate space:** particle positions are LOCAL to the system. The system's `getGlobalTransform()` is applied on top during rendering - both the WebGL2 and WebGPU shaders multiply `projection * translation * rotated`. Setting world-space positions on individual particles double-translates. Position the system itself via `system.setPosition(...)` and emit relative to `(0, 0)`. **View culling:** a system is created with `cullable = false`. Its local bounds cover one texture frame at the local origin, because the particles themselves are simulated on the GPU in half the configurations and no emitted extent is tracked in either - so culling against those bounds would remove the entire cloud as soon as the emitter's own origin left the view. For a system whose reach is known, set the node's `cullArea` to a rectangle in local space covering where its particles travel and set `cullable = true` again; the viewport check then uses that rectangle instead of the bounds. `getBounds()` still reports the one-frame box, not an extent of the live particles. **Pixel snapping:** Drawable.pixelSnapMode is intentionally ignored for particle systems. Particle instances bake their own per-particle transforms in the emitter/compute path rather than reading the shared pixel-snap transform row, so a snap mode set on the system has no effect on rendered output - snapping thousands of independently-moving sub-pixel particles to the device grid is neither meaningful nor desirable.",
+ "description": "The central coordinator of the particle pipeline. `ParticleSystem` is a Drawable that owns: - **Particle storage** - one channel per attribute (position, velocity, scale, rotation, color, timing, ...), sized to a fixed capacity at construction. Modules and render modes address it by name through a ParticleBatch; user code brings particles into existence with emit. - **Spawn modules** - fill freshly emitted particles. - **Update modules** - mutate the live range each frame (forces, color blends, scale curves, drag, ...). Built-in modules ship both CPU and WGSL implementations; custom modules can opt into GPU acceleration by implementing `wgsl()`. - **Death modules** - fire once per dying particle, before its slot is recycled (sub-emitters, event hooks). - **An explicitly supplied render mode** - destroyed with the system. The default mode and the texture are shared and stay the caller's. **Auto-routing CPU vs GPU:** on the first update, and again after any module change, the system checks whether a WebGPU device is available (the attached backend's or one passed in the options), every registered update module has `wgsl()`, and the render mode is GPU-eligible. If so, a composite compute pipeline runs integration plus all module bodies in one dispatch and writes directly into the renderer's instance buffer (no CPU readback). Otherwise the CPU path runs the per-module `apply()` loops; on WebGL2 that is always the case. A module change that forces a running GPU simulation back onto the CPU clears the live particles, because the CPU holds no copy of the state the device integrated. **Per-frame order in update (CPU mode):** 1. Run every spawn module. 2. Integrate position from velocity, rotation from rotationSpeed, advance `elapsed`. 3. Run every update module on the live range. 4. Compact: scan `[0, liveCount)` forward, fire death modules on expired slots, copy survivors down. `liveCount` shrinks to the survivor count. **Per-frame order in update (GPU mode):** 1. Run every spawn module (CPU writes initial values into the spawn slot). 2. Detect expiries on CPU (via `elapsed >= lifetime`); fire death modules; mark the slot dead so the GPU shader skips it. **No compaction** - slots are recycled on the next spawn. 3. Dispatch the composite compute pipeline. Integration, update modules and instance packing run in one pass; the instance buffer is written directly. Tick a system from exactly one place: register it with one system registry, or call update yourself, never both. **Coordinate space:** particle positions are LOCAL to the system. The system's `getGlobalTransform()` is applied on top during rendering, so setting world-space positions on individual particles double-translates. Position the system itself via `system.setPosition(...)` and emit relative to `(0, 0)`. **View culling:** a system is created with `cullable = false`. Its bounds cover one texture frame at the local origin, because no emitted extent is tracked - so culling against them would remove the entire cloud as soon as the emitter's own origin left the view. For a system whose reach is known, set the node's `cullArea` to a world-space rectangle covering where its particles travel and set `cullable = true` again; the viewport check then uses that rectangle instead of the bounds. `getBounds()` still reports the one-frame box, not an extent of the live particles. **Pixel snapping:** Drawable.pixelSnapMode has no effect on particle systems. Particle instances bake their own per-particle transforms rather than reading the shared pixel-snap transform, and snapping thousands of independently moving sub-pixel particles to the device grid is not meaningful.",
"symbol": "ParticleSystem",
"kind": "class",
"subsystem": "particles",
@@ -20,13 +20,14 @@
"members": [],
"paragraphs": [
"The central coordinator of the particle pipeline. `ParticleSystem` is a Drawable that owns:",
- "- **Particle storage** - one channel per attribute (position, velocity, scale, rotation, color, timing, ...), sized to a fixed capacity at construction. Modules and render modes address it by name through a ParticleBatch; user code brings particles into existence with emit. - **Spawn modules** - fill freshly emitted particles. - **Update modules** - mutate the live range each frame (forces, color blends, scale curves, drag, ...). Built-in modules ship both CPU and WGSL implementations; custom modules can opt into GPU acceleration by implementing `wgsl()`. - **Death modules** - fire once per dying particle, before its slot is recycled (sub-emitters, event hooks).",
- "**Auto-routing CPU vs GPU:** at first update, the system checks: if a `WebGpuBackend` was supplied AND every registered update module has `wgsl()` AND the render mode is GPU-eligible, the GPU path engages - a composite compute pipeline runs integration plus all module bodies in one dispatch and writes directly into the renderer's instance buffer (no CPU readback). Otherwise the CPU path runs the existing per-module `apply()` loops.",
+ "- **Particle storage** - one channel per attribute (position, velocity, scale, rotation, color, timing, ...), sized to a fixed capacity at construction. Modules and render modes address it by name through a ParticleBatch; user code brings particles into existence with emit. - **Spawn modules** - fill freshly emitted particles. - **Update modules** - mutate the live range each frame (forces, color blends, scale curves, drag, ...). Built-in modules ship both CPU and WGSL implementations; custom modules can opt into GPU acceleration by implementing `wgsl()`. - **Death modules** - fire once per dying particle, before its slot is recycled (sub-emitters, event hooks). - **An explicitly supplied render mode** - destroyed with the system. The default mode and the texture are shared and stay the caller's.",
+ "**Auto-routing CPU vs GPU:** on the first update, and again after any module change, the system checks whether a WebGPU device is available (the attached backend's or one passed in the options), every registered update module has `wgsl()`, and the render mode is GPU-eligible. If so, a composite compute pipeline runs integration plus all module bodies in one dispatch and writes directly into the renderer's instance buffer (no CPU readback). Otherwise the CPU path runs the per-module `apply()` loops; on WebGL2 that is always the case. A module change that forces a running GPU simulation back onto the CPU clears the live particles, because the CPU holds no copy of the state the device integrated.",
"**Per-frame order in update (CPU mode):** 1. Run every spawn module. 2. Integrate position from velocity, rotation from rotationSpeed, advance `elapsed`. 3. Run every update module on the live range. 4. Compact: scan `[0, liveCount)` forward, fire death modules on expired slots, copy survivors down. `liveCount` shrinks to the survivor count.",
- "**Per-frame order in update (GPU mode):** 1. Run every spawn module (CPU writes initial values into the spawn slot). 2. Detect expiries on CPU (via `elapsed >= lifetime`); fire death modules; set `lifetime[slot] = -1` sentinel + clear `alive[slot]` so the GPU shader skips them. **No compaction** - slots are recycled on next spawn. 3. Dispatch the composite compute pipeline. Integration + update modules + pack-instances run in one pass; the instance buffer is written directly. CPU SoA stays as-is for spawn writes.",
- "**Coordinate space:** particle positions are LOCAL to the system. The system's `getGlobalTransform()` is applied on top during rendering - both the WebGL2 and WebGPU shaders multiply `projection * translation * rotated`. Setting world-space positions on individual particles double-translates. Position the system itself via `system.setPosition(...)` and emit relative to `(0, 0)`.",
- "**View culling:** a system is created with `cullable = false`. Its local bounds cover one texture frame at the local origin, because the particles themselves are simulated on the GPU in half the configurations and no emitted extent is tracked in either - so culling against those bounds would remove the entire cloud as soon as the emitter's own origin left the view. For a system whose reach is known, set the node's `cullArea` to a rectangle in local space covering where its particles travel and set `cullable = true` again; the viewport check then uses that rectangle instead of the bounds. `getBounds()` still reports the one-frame box, not an extent of the live particles.",
- "**Pixel snapping:** Drawable.pixelSnapMode is intentionally ignored for particle systems. Particle instances bake their own per-particle transforms in the emitter/compute path rather than reading the shared pixel-snap transform row, so a snap mode set on the system has no effect on rendered output - snapping thousands of independently-moving sub-pixel particles to the device grid is neither meaningful nor desirable."
+ "**Per-frame order in update (GPU mode):** 1. Run every spawn module (CPU writes initial values into the spawn slot). 2. Detect expiries on CPU (via `elapsed >= lifetime`); fire death modules; mark the slot dead so the GPU shader skips it. **No compaction** - slots are recycled on the next spawn. 3. Dispatch the composite compute pipeline. Integration, update modules and instance packing run in one pass; the instance buffer is written directly.",
+ "Tick a system from exactly one place: register it with one system registry, or call update yourself, never both.",
+ "**Coordinate space:** particle positions are LOCAL to the system. The system's `getGlobalTransform()` is applied on top during rendering, so setting world-space positions on individual particles double-translates. Position the system itself via `system.setPosition(...)` and emit relative to `(0, 0)`.",
+ "**View culling:** a system is created with `cullable = false`. Its bounds cover one texture frame at the local origin, because no emitted extent is tracked - so culling against them would remove the entire cloud as soon as the emitter's own origin left the view. For a system whose reach is known, set the node's `cullArea` to a world-space rectangle covering where its particles travel and set `cullable = true` again; the viewport check then uses that rectangle instead of the bounds. `getBounds()` still reports the one-frame box, not an extent of the live particles.",
+ "**Pixel snapping:** Drawable.pixelSnapMode has no effect on particle systems. Particle instances bake their own per-particle transforms rather than reading the shared pixel-snap transform, and snapping thousands of independently moving sub-pixel particles to the device grid is not meaningful."
],
"importLine": "import { ParticleSystem } from '@codexo/exojs-particles'",
"sourceLink": null
diff --git a/site/src/content/api/render-pass-inspector-layer.json b/site/src/content/api/render-pass-inspector-layer.json
index 8f96915a4..954e551c5 100644
--- a/site/src/content/api/render-pass-inspector-layer.json
+++ b/site/src/content/api/render-pass-inspector-layer.json
@@ -1,6 +1,6 @@
{
"title": "RenderPassInspectorLayer",
- "description": "Debug layer that lists every RenderNode with an active filter chain each frame. Renders a compact text panel with per-drawable rows showing the filter sequence, bounding-box dimensions, and mask/cache status. Use during development to answer: - \"Is my filter actually attached?\" → it appears in the list - \"Why does my frame have N render passes?\" → see total pass count - \"Is this drawable being re-rendered or cached?\" → `[cached]` flag For deep per-pass inspection (intermediate render-target contents, GLSL/WGSL source, uniform values), use Spector.js or Chrome DevTools' WebGPU panel - the engine emits debug-group labels around filter and mesh-custom-shader passes so those tools show meaningful pass names.",
+ "description": "Debug layer that lists every visible RenderNode in the scene tree with an attached filter chain, and optionally a RenderPipeline set with setPipeline. Renders a compact text panel with per-node rows showing the filter sequence, bounding-box dimensions, and mask/cache status. Use during development to answer: - \"Is my filter actually attached?\" - it appears in the list - \"Is this node configured to render through a cache?\" - `[cached]` flag The pass total counts the attached filters plus one per collected entry with a mask. It is a structural estimate, not a hardware-pass count or GPU timing: multi-step filters are not expanded, cached work is not subtracted, and mask-only nodes are not collected. The returned entries are reused on every update. For deep per-pass inspection (intermediate render-target contents, GLSL/WGSL source, uniform values), use Spector.js or Chrome DevTools' WebGPU panel. On WebGPU, draws with a custom mesh or sprite material carry debug-group labels, so those tools can tell them apart.",
"symbol": "RenderPassInspectorLayer",
"kind": "class",
"subsystem": "debug",
@@ -19,10 +19,11 @@
"title": "Import",
"members": [],
"paragraphs": [
- "Debug layer that lists every RenderNode with an active filter chain each frame. Renders a compact text panel with per-drawable rows showing the filter sequence, bounding-box dimensions, and mask/cache status.",
+ "Debug layer that lists every visible RenderNode in the scene tree with an attached filter chain, and optionally a RenderPipeline set with setPipeline. Renders a compact text panel with per-node rows showing the filter sequence, bounding-box dimensions, and mask/cache status.",
"Use during development to answer:",
- "- \"Is my filter actually attached?\" → it appears in the list - \"Why does my frame have N render passes?\" → see total pass count - \"Is this drawable being re-rendered or cached?\" → `[cached]` flag",
- "For deep per-pass inspection (intermediate render-target contents, GLSL/WGSL source, uniform values), use Spector.js or Chrome DevTools' WebGPU panel - the engine emits debug-group labels around filter and mesh-custom-shader passes so those tools show meaningful pass names."
+ "- \"Is my filter actually attached?\" - it appears in the list - \"Is this node configured to render through a cache?\" - `[cached]` flag",
+ "The pass total counts the attached filters plus one per collected entry with a mask. It is a structural estimate, not a hardware-pass count or GPU timing: multi-step filters are not expanded, cached work is not subtracted, and mask-only nodes are not collected. The returned entries are reused on every update.",
+ "For deep per-pass inspection (intermediate render-target contents, GLSL/WGSL source, uniform values), use Spector.js or Chrome DevTools' WebGPU panel. On WebGPU, draws with a custom mesh or sprite material carry debug-group labels, so those tools can tell them apart."
],
"importLine": "import { RenderPassInspectorLayer } from '@codexo/exojs/debug'",
"sourceLink": null
diff --git a/site/src/content/guide/assets/asset-catalogs.mdx b/site/src/content/guide/assets/asset-catalogs.mdx
new file mode 100644
index 000000000..26dbd6610
--- /dev/null
+++ b/site/src/content/guide/assets/asset-catalogs.mdx
@@ -0,0 +1,52 @@
+---
+title: 'Asset catalogs'
+description: 'Name a group of assets, compose shared definitions, and distinguish deferred catalog leaves from resolved load results.'
+---
+
+import SourceSnippet from '../../../components/SourceSnippet.astro';
+
+# Asset catalogs
+
+A catalog names the resources used by a feature without deciding when they are loaded or how long they stay resident. A loader scope supplies that lifetime. This separation lets two levels share a definition without making either level responsible for the other's cleanup.
+
+Read [Loading and resource lifetimes](/ExoJS/en/guide/assets/loading-and-resources/) first. For one asset, an individual descriptor is sufficient; a catalog becomes useful when a feature has several named inputs.
+
+## Declare once, acquire through an owner
+
+
+
+Creating `SharedAssets` does not fetch files. Its texture property is a deferred resource handle; its JSON property is an `AssetRef`. Acquiring the catalog through a scope starts the work and gives that scope claims on its assets.
+
+The generic annotation on `Asset.type('json', ...)` expresses an expected TypeScript shape. **It does not validate the downloaded JSON.** Use a synchronous `parse` transform or validate the decoded `unknown` value when the document is not fully controlled by your build pipeline. Do not let a type annotation stand in for validation of a save file, server response, or user-supplied document.
+
+## Two ways to use the completed load
+
+
+
+The returned object contains finished values: `loaded.settings` is `Settings`, not `AssetRef`. The original catalog still exposes `SharedAssets.settings`, whose `.value` is now usable. `loaded` is a newly created values map, not the catalog instance.
+
+Code that needs values immediately can use the returned map. Code that binds a texture handle before the load completes can retain the catalog leaf and observe its readiness. Do not replace every catalog leaf after loading; filling existing leaves is part of their purpose.
+
+A failed catalog load rejects its queue. Individual leaves retain their own status, so an error UI can identify the failing asset. A successful sibling is not proof that the catalog as a whole succeeded.
+
+## Compose shared definitions
+
+
+
+`compose` combines catalogs and preserves their shared leaves. `extend` derives a catalog with explicitly redeclared or additional keys instead of mutating its base. Use descriptive keys and avoid accidental collisions when composing independent features; consult the [`Assets` contract](/ExoJS/en/api/assets/) for collision handling.
+
+A catalog is not a global asset lifetime. Loading the same composed definition through two scopes creates two owners of the shared payload. Destroying one scope does not invalidate the other owner's claim. Once no owner keeps an asset resident, keeping a catalog object in a module is not a promise that its payload remains ready forever.
+
+## Parallel loads and progress
+
+Start independent queues before awaiting them, then use `Promise.all` when both are required. Each queue's progress describes that acquisition group. Aggregate loader signals describe wider activity and can include work started elsewhere.
+
+Background priority belongs to a catalog or catalog leaf load. Per-resource decode options belong in `Asset.type(...)`. Those two option bags solve different problems and are not interchangeable.
+
+## Keep definitions close to the feature
+
+Place a small shared catalog beside the code that consumes it, then compose it into the level or UI definition that needs it. Avoid one application-wide catalog that eagerly loads every possible screen. For streaming, make the level runtime own its scope and let its catalog describe only its inputs.
+
+Streamed music, video, browser fonts, and other types without placeholder leaves are normally loaded with explicit awaited descriptors. Do not assume that an arbitrary resource can participate in every catalog or `get()` form just because it has an asset type.
+
+Continue with [Worlds and level streaming](/ExoJS/en/guide/assets/worlds-and-spawning/) for independently unloadable content, or the [`Assets`](/ExoJS/en/api/assets/), [`LoaderScope`](/ExoJS/en/api/loader-scope/), and [`AssetRef`](/ExoJS/en/api/asset-ref/) references for exact contracts.
diff --git a/site/src/content/guide/assets/loading-and-resources.mdx b/site/src/content/guide/assets/loading-and-resources.mdx
index 0ddb9a402..399bb24f4 100644
--- a/site/src/content/guide/assets/loading-and-resources.mdx
+++ b/site/src/content/guide/assets/loading-and-resources.mdx
@@ -1,680 +1,101 @@
---
-title: 'Loading and resources'
-description: 'The asset pipeline — how the loader registers, fetches, and resolves resources before your scene starts updating.'
+title: 'Loading and resource lifetimes'
+description: 'Choose awaited or placeholder-based loading, handle failures, and give assets the lifetime of their actual owner.'
---
-import ExamplePreview from '../../../components/ExamplePreview.astro';
-import Callout from '../../../components/Callout.astro';
import SourceSnippet from '../../../components/SourceSnippet.astro';
+import TryIt from '../../../components/TryIt.astro';
-# Loading and resources
+# Loading and resource lifetimes
-Most non-trivial scenes need assets — textures, audio, fonts, JSON, video — before they can render. The [`Loader`](/ExoJS/en/api/loader/) handles fetching, decoding, and caching those assets, then makes them available to your scene by name.
+Loading answers two questions: **when is a value usable, and who keeps it alive?** The loader handles acquisition and decoding. A loader scope holds claims that keep the resulting resources resident.
-The contract is simple: declare what you need during `load`, await the loader, and read the resolved instances out during `init`. Everything before `init` runs has finished by the time `init` is called.
+Use `this.loader` inside a scene for scene-owned resources. Use `this.app.loader` only for resources that should survive until the application ends. For a streamed level, dialog, or preview with a shorter lifetime, create a child scope and destroy it when that activity ends.
-## The loader instance
+## Await a required resource
-Every application owns one loader, available as `app.loader`. Inside a scene, reach it two ways: `this.loader` — a scene-scoped claim view whose assets release automatically when the scene ends — and `this.app.loader` — the same underlying application-lifetime instance, for assets that must outlive the scene.
+Load a resource before activating a scene when layout or gameplay needs its real dimensions or decoded value:
-For most scenes you don't need to think about ownership — just use `this.loader` in `load` and `init`.
+
-## Choose the form that fits
+Place an image at `public/assets/image/hero.png` and configure `loader.basePath: 'assets/'` when starting this scene. `public` is a project directory, not part of the requested URL. Resolve deployment base paths as described in [Deployment](/ExoJS/en/guide/shipping/deployment/).
-The loader offers several forms. Pick the smallest one that covers your case:
+A registered literal suffix such as `.png` supplies the asset type. For a computed path, ambiguous file name, or type without a placeholder, use an explicit descriptor:
-| Form | Best for |
-| ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
-| `this.loader.load('hero.png')` | One asset, path doubles as its identity — no alias needed |
-| `Asset.type('texture', path)` / `Asset.type('sound', path)` / `Asset.type('json', path)` | The path is computed at runtime (not a literal), or you want an explicit type |
-| `Assets.from({ hero: 'hero.png', … })` | A reusable, named group of assets shared across scenes |
-| `Assets.from({ hero: 'hero.png', music: Asset.type('music', path) })` | Mixed types and type-specific options in one catalog |
-
-When in doubt, start with a bare path string. Reach for `Assets.from` when assets belong together, and `Asset.type(...)` whenever the path isn't a string literal or the type should be explicit.
-
-
- Awaiting loads sequentially makes each one wait for the previous download to finish. Group independent `this.loader.load(...)` calls in a single `Promise.all` so unrelated resource types fetch concurrently.
-
-
-## Which call when
-
-The forms above say how to name an asset; these are the calls that fetch, look up and let go of one. Every fetching call claims the asset for its owner (the application, the scene, or a scope you created), and a claim is what keeps it resident.
-
-| You want to… | Call | You get | Claim |
-| ------------------------------------------------------------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------- | --------------------------------------- |
-| Use a seamless asset now and let it fill in when it arrives | `loader.get('hero.png')` | The handle immediately (`'loading'` until ready), the same instance for the same source | Yes |
-| Read a value asset (`json`, `txt`, `csv`, …) as it becomes available | `loader.get('level.json')` | A stable `AssetRef` whose `.value` fills in | Yes |
-| Wait for one asset, or for a type without a placeholder (`music`, `bmFont`, …) | `await loader.load(Asset.type('music', path))` | The resolved resource | Yes |
-| Wait for a whole catalog at once | `await loader.load(catalog)` | The catalog, its leaves healed in place | Yes, one per entry |
-| Unpack a packed `.exoa` container in one request | `await loader.loadContainer('pack.exoa')` | A new scope owning every entry; entries resolve to the same identities as network loads | Yes, one per entry, held by that scope |
-| Find out which packs a deployment ships, and where they are now | `await loader.loadManifest('assets.json')` | An `AssetManifest`; `manifest.pack(name)` addresses one for `loadContainer` | No |
-| Check whether something is already resident without fetching | `loader.peek('hero.png')` | The resource, or `undefined` | No |
-| Let one asset go before its owner ends | `scope.release(handle)` | Nothing; the handle stays valid and heals again on the next `get()` | Drops this scope's claim |
-| Let everything an owner holds go | `scope.destroy()` / the scene ending | Nothing; assets other owners still hold stay resident | Drops all of that owner's claims |
-
-Two rules make the table predictable. First, `get()` never returns `undefined` for a seamless type: it hands back a placeholder and starts the fetch, so a wrong path fails at the fetch, not at the call - use `peek()` when "not loaded" is an answer you want. Second, `release()` and `destroy()` only ever drop the caller's own claims; there is no call that evicts an asset another owner is using, and no way to release an application-lifetime claim except destroying the application. Read on for the forms and their examples, and see [Ownership and scopes](#ownership-and-scopes) for what an owner is.
-
-## Loading a single asset
-
-The path _is_ the identity — a bare string resolves directly to the finished asset, with its type inferred from the extension:
-
-
-
-There's no separate alias step: call `this.loader.get('image/hero.png')` again anywhere later — same string, same `Texture` instance. Leaf-capable types such as `texture`, `sound`, `json`, and `text` resolve this way from their file extension. Types that can't be inferred from a path (`music`, `video`, `bmFont`, `font`, …) need an explicit descriptor — see [`Asset.type(...)`](#dynamic-paths-and-non-seamless-types) below.
-
-## Homogeneous batch
-
-When you need several assets of the same type, load them in parallel and keep the returned instances:
-
-
-
-The `loader.basePath` option in `ApplicationOptions` prepends a base prefix to all relative paths, so these resolve to e.g. `assets/image/hero.png`.
-
-Because the path is the identity, the same source path returns the very same `Texture` instance from anywhere else in your code — `this.loader.get('image/hero.png')` from `init`, a later scene, or `app.loader.get(...)` from a system — as long as the string matches.
-
-
- For a registered seamless or value suffix, `this.loader.get(...)` returns synchronously — even before the fetch finishes. Ask for a valid path you haven't loaded yet and you get a placeholder handle/ref immediately, which then heals once the network request resolves. Invalid input or a missing type/handler still throws synchronously. Check `.ready` / `.state` for asynchronous fetch outcomes (see [Status channel](#status-channel-instead-of-throwing) below).
-
-
-## Dynamic paths and non-seamless types
-
-A bare string only works for a _literal_ path — the type is inferred at compile time from the extension. When the path is computed at runtime, use the canonical `Asset.type(...)` descriptor:
-
-
-
-Inside every scene lifecycle hook, `Scene.app` is already available and non-null. Access before attachment, such as from a scene constructor, throws; use `Scene.attached` only when code genuinely needs a non-throwing attachment probe.
-
-Types that can't be inferred from a path at all need the same descriptor, even when the path _is_ a literal:
-
-
-
-
- For seamless and value types, `this.loader.get(Asset.type(type, dynamicPath))` returns a handle or `AssetRef` immediately and starts the asynchronous load. Capture that returned object: each descriptor call creates a fresh leaf, although the backend fetch is still deduplicated. Non-leaf types have no synchronous handle and must use `await this.loader.load(Asset.type(...))`.
-
-
-## Multiple resource types in parallel
-
-When a scene needs different asset types, load them in parallel with `Promise.all`:
-
-
-
-Wrapping the calls in `Promise.all` lets unrelated asset categories load in parallel.
-
-For value assets such as `Json` (and `TextAsset`, `CsvAsset`, and the other data tokens), `loader.load(...)` resolves directly to the parsed value. `loader.get(...)` on the same path hands back a lightweight `AssetRef` instead — read its `.value` once `.ready` is `true` (see below).
-
-## Mixed asset catalog
-
-Build mixed groups with `Assets.from(...)`. This is the canonical catalog API; the old inline-record `loader.load({ alias: config })` call shape has been removed.
-
-
-
-Awaiting the catalog returns an object whose keys match the input and whose values are the resolved resource instances directly, so it destructures:
-
-
-
-No separate `get()` step is needed, though `this.loader.get('image/uv-grid-256.png')` / `this.loader.get('audio/ui-click.ogg')` still work afterward since the source path remains the identity.
-
-## Reusable asset references
-
-For assets used in multiple scenes, define a named catalog with `Assets.from`:
-
-
-
-Every property is a real, usable handle _before_ any loader touches it — `TitleAssets.logo` is already a `Texture` (in the `'loading'` state), and `TitleAssets.config` is already an `AssetRef`. Load the whole catalog, then read the same properties:
-
-
-
-Typed properties on the catalog give autocomplete and type inference for free:
-
-
-
-`Assets` also exposes an `.entries` record for iteration and inspection. To load several existing catalogs concurrently, keep their types intact and start both queues:
-
-
-
-The key `'entries'` is reserved and will throw if you try to use it as an asset name inside an `Assets` container.
-
-## Combining and deriving catalogs
-
-Catalogs compose. `Assets.compose(...)` merges several existing catalogs into one — the result is an ordinary, fully typed `Assets` object, so it loads, releases, and autocompletes exactly like a hand-written one:
-
-
-
-A composition **shares** its inputs' handles instead of copying them: `LevelAssets.logo === SharedAssets.logo`, so loading the composition heals the handles the shared catalog already handed out. It adds no ownership of its own — loading and releasing behave exactly as they would for the underlying keys.
-
-Two _different_ catalogs may not define the same key. That ambiguity is caught at compile time (the result types as a message naming the key) and always throws at runtime:
+```ts
+import { Asset, type LoaderScope } from '@codexo/exojs';
-```ts no-check -- shows the duplicate-key error Assets.compose reports, not working code
-// Assets.compose(): duplicate catalog key "ship" — two catalogs define it,
-// use Assets.extend() to override it deliberately.
-Assets.compose(LevelLocalAssets, Assets.from({ ship: 'image/other-ship.png' }));
+export const loadPortrait = (scope: LoaderScope, name: string) =>
+ scope.load(Asset.type('texture', `portraits/${name}.png`));
```
-The same catalog arriving twice along different paths — a diamond — is not a conflict and deduplicates:
-
-
-
-To re-declare a key on purpose, derive with `Assets.extend(base, entries)`. New keys are added, existing keys are deliberately overridden, and the base catalog is never mutated:
-
-
-
-An override is a new declaration, so composing a derived catalog back together with the base it overrode conflicts on that key — as two independent declarations should.
-
-## Progress and parallel loading
-
-Every `loader.load(...)` call returns a `LoadingQueue`. It implements `PromiseLike`, so `await` and `Promise.all` both work. It also exposes per-queue progress via `onProgress`:
-
-
-
-`LoadingProgress` fields:
-
-| Field | Meaning |
-| --------- | ------------------------------------------- |
-| `total` | Total assets in this queue |
-| `loaded` | Successfully loaded so far |
-| `failed` | Failed so far |
-| `pending` | Not yet settled (`total − loaded − failed`) |
-
-Start two queues simultaneously and wait for both:
-
-
-
-Each queue tracks its own progress independently. The result tuple is typed: `day` has the shape of `LevelAssets`, `night` has the shape of `NightAssets`.
-
-A practical startup pattern — show the title screen as soon as title assets are ready, while game assets continue loading in the background:
-
-
-
-## Signals for a global loading screen
-
-`LoadingQueue.onProgress` (above) is scoped to a single `loader.load(...)` call. When you want a single loading screen that reflects _everything_ the loader is doing — regardless of how many separate `loader.load(...)` calls triggered it — subscribe to the loader instance's own signals instead: `onLoadStart`, `onLoadProgress`, `onLoadComplete`, and `onLoadError`. These track one shared, loader-wide batch: as long as any foreground load is in flight, new loads join the same batch instead of starting a new one.
-
-| Signal | Payload | Fires |
-| ---------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `onLoadStart` | `(key, url)` | Once, when the loader goes from idle to active. `key`/`url` identify the asset that triggered it. |
-| `onLoadProgress` | `(loaded, total, key)` | After every asset in the batch settles (success or failure). `loaded`/`total` are running counts across the whole batch; `key` is the asset that just settled. |
-| `onLoadComplete` | — | Once, when every in-flight foreground load has settled and the batch returns to idle. |
-| `onLoadError` | `(key, error)` | For each asset that fails. Does not prevent `onLoadComplete` from firing afterward. |
-
-Because the batch is shared, `total` can grow mid-flight: if a new `loader.load(...)` call starts while others are still pending, its assets are added to the same running total rather than starting a fresh count at 0. That is exactly what you want for a boot screen — one progress bar that stays accurate no matter how many scenes or systems kick off loads concurrently.
-
-
-
-Unlike `LoadingQueue.onProgress`, which you attach to the return value of one `loader.load(...)` call, these four are properties on the `Loader` itself — subscribe once (for example in your boot scene's `init`) and they report every foreground load for the lifetime of that loader.
-
-### Treat completion as state
-
-`onLoadComplete` is not tied to this scene's lifecycle. With a warm cache it fires while `BootScene` is still preparing, before the scene may navigate; after a switch away from `BootScene` or an app-level `stop()`/`destroy()` mid-load it fires for a scene that is no longer on screen. Navigating directly from the handler loses the first case and misfires in the second. Record the completion instead, act on it from `update()` — which only runs while the scene is active — and unsubscribe from the loader signals in `unload()`:
-
-
-
-## Status channel instead of throwing
-
-For leaf-capable types, the path (or an `Asset.type(...)` descriptor) identifies the loaded payload — there's no separate alias to register or forget. Every handle and `AssetRef` exposes the same small status contract:
-
-
-
-`LoadStateValue` is `'idle' | 'loading' | 'ready' | 'failed'` — `'idle'` is the state of a catalog leaf no loader has adopted yet.
-
-
-
-The same `.state` / `.ready` / `.error` / `.loaded` contract applies to `AssetRef` values such as JSON or text — read `.value` once `.ready` is `true`. A bare path yields an `AssetRef`, so name the payload shape with `Asset.type(...)` when you want to read into it:
-
-
-
-`get()` returns before network work settles. A later load failure moves the handle/ref to `'failed'`, rejects its `.loaded` promise, and is reported through `this.app.loader.onError`; calling `get()` again retries and can heal that same source-keyed handle. By contrast, the awaitable returned by `load()` rejects at the call site on failure and reports the same `Error` through `this.app.loader.onError`. Invalid input or a missing type/handler is a synchronous configuration error for either API. A catalog entry's optional `parse` transform must be synchronous — if it returns a Promise (or anything else thenable), the ref it belongs to fails with an explicit error instead of silently awaiting it; do any asynchronous decoding inside the asset handler's load phase instead.
-
-### Ownership and scopes
-
-An asset stays in memory for as long as somebody owns it. There are three kinds of owner:
-
-- **the application** — anything acquired directly on `this.app.loader` is held until the app is destroyed;
-- **a scene** — anything acquired through `this.loader` (that is, `scene.loader`) is released when the scene ends;
-- **a scope you create yourself** — `this.app.loader.createScope({ name })` returns an owner you destroy when you are done with it.
-
-Several owners can hold the same asset at the same time. They share one fetch and one resident payload, and one owner letting go never invalidates another:
-
-
-
-Create a scope whenever an asset's lifetime is shorter than the application's — a level, a streamed chunk, a UI panel, a prefetch. Every `createScope()` call returns a new owner, never an existing one: the name is a diagnostic label for `inspect()`, never an identifier, so two scopes created under the same name are still two independent owners.
-
-Scopes nest. `scope.createScope({ name })` creates a child that claims independently but cannot outlive its parent: destroying the child frees only the child's claims, while destroying the parent destroys whatever children it still has, recursively. A scope created through `this.loader.createScope(...)` inside a scene is therefore cleaned up with that scene, even if you never destroy it yourself:
-
-
-
-The hierarchy is a lifetime hierarchy only: it never affects asset identity or what a release frees. A child holding the same asset as its parent is two claims, not one.
-
-`scope.release(...)` drops that scope's claim on one asset; the object itself keeps its identity and heals back to `'loading'` if you `get()` the same source again. It accepts a handle, an `Asset` descriptor, a whole catalog, or a `(type, source)` pair. Releasing a valid form that scope never claimed is a harmless no-op, and releasing twice is always idempotent:
-
-
-
-There is deliberately no way to release an application-lifetime claim: `app.loader.get(...)` means "I want this for as long as the app runs". Anything you intend to free later is acquired through a scene or a scope instead.
+`Asset.type(...)` describes an asset; constructing it does not fetch anything. Per-asset options belong in that descriptor. Streamed music, video, bitmap fonts, and browser fonts require explicit descriptors and awaited loading rather than pretending that every resource supports placeholder access.
-
- Passing something that isn't a handle, descriptor, catalog, or `(type, source)` pair — a plain object, a resolved non-seamless resource, anything `release()` can't resolve to a claim — throws instead of doing nothing. If you loaded a non-seamless type by reference (`load(Asset.type('bmFont', ...))`), release it with the same `Asset` descriptor or the `(type, source)` pair, not the resolved resource itself.
-
+## Use a placeholder deliberately
-
- Types that aren't seamless — `AudioStream`, `Video`, `BmFont`, `FontAsset`, and custom types — have no placeholder to hand back, so there's nothing to `get()` speculatively. Load them by reference and hold onto the resolved value: `const music = await this.loader.load(Asset.type('music', 'audio/theme.ogg'))`. There is no `has()`/`peek()` guard — for a seamless handle read `.ready` / `.state`; for a non-seamless resource, `await` the load (or its returned handle's `.loaded` promise) before you use it.
-
+`get()` returns synchronously and acquires a claim. It is **not** a passive cache lookup. For supported resource types it returns a handle that can be used while loading and is filled in place. For value types such as JSON it returns an `AssetRef`; read `.value` only after it is ready.
-### Streaming media
+Use a placeholder for optional decoration or progressive content whose layout does not depend on the final result. Observe the handle's readiness and error state, or await its `loaded` promise when a later operation needs completion. Do not assume an immediate `get()` has finished a network request.
-`music` and `video` assets are streamed by the browser: the loader hands the media element the resolved URL and lets it pull the file in as it plays. Nothing is buffered into memory up front, which is what makes a long track or a full-screen video affordable.
+A bare-path `get` reuses the source-keyed handle. Repeated `get(Asset.type(...))` calls instead construct distinct leaves that share the underlying payload. Capture a descriptor-based handle once, not once per frame.
-
+Use the loader's `peek` API to inspect residency without acquiring or starting work. Choose it for diagnostics, not for retaining a resource that gameplay is about to use.
-A streamed asset is **ready when it can start playing**, not when it has fully arrived — the load resolves on the element's `canplay` event. Two consequences are worth internalising:
+## Handle failures at the calling boundary
-- Progress for a streamed asset is per asset, not per byte. The loader cannot know how much of a browser-owned transfer has landed, and it does not pretend to.
-- A failure *before* readiness fails the load and is reported through `loader.onError`. A failure *after* readiness — the connection dropping mid-playback — is a runtime media error and is reported by `video.onError` / `stream.onError` instead, so one load never appears to fail twice.
-
-Streamed elements get `crossOrigin: 'anonymous'` by default. This matters for video: a cross-origin element without it plays, but cannot be uploaded as a texture. Pass `crossOrigin: null` for playback-only media on a host that sends no CORS headers, accepting that it cannot be rendered.
-
-Ask for the complete bytes when you want ExoJS to own them — that is a separate operation, not a variant of the load:
-
-
-
-`cacheSource` fetches the whole file through the loader's cache pipeline and persists it, without building an element or making anything resident. It is on the application's loader rather than a scene's: nothing it does belongs to a scope, because nothing it does is owned. That is what makes media available offline, and it is the same thing a container (`.exoa`) entry does — container bytes are already owned by the application. See [Working offline](/ExoJS/en/guide/assets/offline/).
-
-The descriptor is the same one you load. Whichever transport an asset arrives through, it is the same canonical asset: a source streamed from the network, one unpacked from a container and one read back from a cache resolve to one identity and one resident resource, never two.
-
-The CORS mode is the exception, because it is baked into the element rather than into the bytes: `crossOrigin: null` and the default `'anonymous'` for one URL are two assets, and so is `'use-credentials'`. Nobody is ever handed an element whose CORS mode they did not ask for — a video that cannot be a texture never arrives where a texture was expected.
-
-
- `this.app.loader.inspect()` returns a frozen, sorted snapshot — one row per canonical asset, with its state, how many owners hold it, and whether it's in flight or still queued in the background — for debugging or a support bundle. It's read-only: nothing you do with the returned array changes what's actually loaded.
-
-
-## When are resources available?
-
-The lifecycle guarantees that:
-
-- Inside `load`, you can `await this.loader.load(...)` to fetch assets.
-- `init` runs once `load` is complete — it is safe to read assets there, and every seamless handle you loaded is already `.ready`.
-- After `init`, `this.loader` is still available. Subsequent calls to `this.loader.get('same/path.png')` from `update`, `draw`, or other places return the same instance.
-- Calling `this.loader.get('a/path/you-never-loaded.png')` from anywhere kicks off the fetch itself and hands back a `'loading'` placeholder — check `.ready` before relying on the payload being present.
-
-## Loading on demand
-
-`this.loader.load(...)` (or `this.app.loader.load(...)` for an app-lifetime claim) works any time, not just inside the `load` hook — the [`Asset.type(...)`](#dynamic-paths-and-non-seamless-types) call shown earlier is exactly that case:
-
-
-
-The frame loop continues running while the promise is pending.
-
-## Loading per game phase
-
-Split a large project into per-phase catalogs and load each one as the player reaches it, instead of fetching everything up front. A catalog is just an `Assets.from(...)` group (see [Reusable asset references](#reusable-asset-references)):
-
-
-
-Load the menu catalog once and keep it resident, then load level catalogs as the player progresses:
-
-
-
-Loading is idempotent: a catalog whose leaves are already resident resolves immediately without re-fetching, so you can call `this.app.loader.load(Level1Assets)` again from anywhere without a guard. Treat catalog names and their keys as part of your project's asset contract.
-
-### Progress for a loading screen
-
-`loader.load(catalog)` returns a `LoadingQueue` — attach `onProgress` for a per-catalog bar, exactly as in [Progress and parallel loading](#progress-and-parallel-loading):
-
-
-
-For one screen that reflects every load regardless of how many catalogs are in flight, use the loader-wide signals from [Signals for a global loading screen](#signals-for-a-global-loading-screen).
-
-### Handling failures
-
-Awaiting a catalog rejects if any leaf fails. Wrap the await and read each leaf's status channel to find which one — a failed seamless handle still renders a visible "missing" fallback, so the scene keeps running:
-
-
-
-There is no separate bundle error type — every leaf carries its own `.state` / `.error` (see [Status channel](#status-channel-instead-of-throwing)).
-
-### Pre-warming in the background
-
-To fetch a catalog ahead of time without blocking the current scene, pass `{ priority: LoadPriority.Background }`. Every leaf is still claimed and heals in place, but its fetch is routed through a low-priority queue while the frame loop keeps running:
-
-
-
-A backgrounded leaf is boosted to fetch immediately if something `get()`s or foreground-`load()`s it before the queue reaches it — so there's nothing to guard against. Request it in the background early, then `await this.app.loader.load(Level2Assets)` when you actually need it and it resolves as soon as the in-flight fetch finishes. `this.app.loader.awaitBackground()` resolves once the background queue has fully drained.
-
-## Caching
-
-By default the loader fetches from the network every session and keeps nothing between them — no cache is configured, and none of the caching machinery runs. To persist what was acquired across sessions, pass a store:
-
-
-
-When constructing the loader through `Application`, pass `LoaderOptions` under the `loader` key:
-
-
-
-That is the whole configuration for the usual case: one store, read and written cache-first. On first load an asset is fetched from the network and written to the store; on later sessions it is read back from the store instead. Every asset type is covered, including one an extension installs at runtime — there is nothing to register and no schema to declare.
-
-### What is cached, and under what identity
-
-The cache holds the **acquired representation**, not the runtime resource. That is the value the type's [`AssetSourceCodec`](/ExoJS/en/api/asset-source-codec/) read off the response, before any interpretation that would have to be redone anyway — the JSON text rather than the parsed object, the bytes rather than the decoded image. A cache hit still runs `codec.decode` and still builds the resource through the factory, so a factory never sees the persisted form and never learns where the source came from.
-
-Each record is identified by four stable values:
-
-| Part | Comes from |
-| -------------- | --------------------------------------------- |
-| namespace | the asset type's `id` |
-| source | the request's `SourceKey` |
-| layout version | the type's `layout.version` |
-| record | the layout's own name for it (`'value'`) |
-
-The source key alone is deliberately not enough: it carries no asset type, so two types acquiring one URL would otherwise overwrite each other's representations. Conversely two resources that differ only in how one download is interpreted share a source key — and therefore one cache record and one download.
-
-Raise `layout.version` when the stored representation changes shape. Records written under the old version stop being found and are re-acquired; there is no migration path, because a cache is reconstructible by definition:
-
-
-
-### Policies
-
-A [`CachePolicy`](/ExoJS/en/api/cache-policy/) decides only the ORDER in which the cache and the network are consulted. Four are built in:
-
-| Policy | Reads cache | Fetches | Writes | On failure |
-| --------------------- | ----------- | ----------------- | ---------------- | -------------------------------------------------------------------------- |
-| `CacheFirstPolicy` | first | only on a miss | what it fetched | a read or write failure degrades; the load still succeeds from the network |
-| `NetworkFirstPolicy` | on fallback | always, first | what it fetched | falls back to the cache only for a transport or HTTP failure |
-| `NetworkOnlyPolicy` | never | always | never | the network failure surfaces |
-| `CacheOnlyPolicy` | only | never | never | a miss rejects with `AssetCacheMissError`, a broken store with `AssetCacheError` |
-
-`CacheFirstPolicy` is the default. `NetworkFirstPolicy` falls back deliberately narrowly: a cancelled load stays cancelled, and a response the codec could not read is a broken source rather than an absent network — serving a stale representation for either would replace a visible failure with a silently wrong asset.
-
-Writing a policy needs nothing but the [`CacheContext`](/ExoJS/en/api/cache-context/) it is handed:
-
-
-
-A policy is never handed a factory, a codec, an asset type or a store handle. It is a stateless object and may be shared between routes and applications: everything one call needs arrives in its context.
-
-### Failure semantics
-
-A cache **miss** and a cache **failure** are different events, and stay different:
-
-- `context.read()` resolves to `{ hit: false }` when no store held the record, and rejects when a store could not answer.
-- `context.write()` rejects when a store refused the write, after attempting every one of them.
-- A store never swallows a failure to look like an empty cache. Whether to degrade is the policy's decision — which is why `CacheOnlyPolicy` can tell "this was never written" from "the database is broken", and `CacheFirstPolicy` can treat both as a reason to fetch.
-
-Every store failure is reported on [`Loader.onCacheError`](/ExoJS/en/api/loader/) before any policy degrades it, so a store that is quietly refusing every write stays diagnosable:
-
-
-
-### Several stores, and per-type routes
-
-For more than one tier, configure an [`AssetCache`](/ExoJS/en/api/asset-cache/):
-
-
-
-Read stores are consulted in the order they were given and the first hit wins — never raced, so which store answered, which failure surfaced and what was promoted are the same on every run. Read and write lists are separate, so a route can read a cache shipped with the application without writing to it, and `promote: true` copies a hit from a later store into the earlier writable ones.
-
-Routes are matched by asset type id, in declaration order; the first route that claims a type wins, and anything no route claims falls to the options given at the top level. A route without `types` claims everything from its position onwards.
-
-To drop cached records — after a content update, or when a player asks for it — call `cache.clear()`, or `cache.clear(typeId)` for one type.
-
-### The persistent store
-
-[`IndexedDbStore`](/ExoJS/en/api/indexed-db-store/) keeps every record of every type in a single generic object store, with the namespace as part of the record key rather than as physical schema. That is what lets a type installed at runtime cache immediately: no schema version bump, no object store, no engine-side registration.
-
-Values are stored through the structured-clone algorithm, so strings, `ArrayBuffer`s, typed arrays, `Blob`s and plain objects all round-trip without a JSON layer. A write resolves only once its transaction has committed.
-
-A database written under an earlier physical schema is **emptied on first open**. Cached representations are re-fetchable by definition, so they are discarded rather than migrated.
-
-[`MemoryCacheStore`](/ExoJS/en/api/memory-cache-store/) is the in-process counterpart: it holds values by reference for the lifetime of the page, which makes it a good front tier and the obvious choice in tests.
-
-## Packs and the asset manifest
-
-A `.exoa` container is compressed in **blocks**, and a block boundary always falls on an entry boundary, so changing one asset changes that asset's blocks and no others. Passing a [`ContainerBlockStore`](/ExoJS/en/api/container-block-store/) to `loadContainer` is what turns that into traffic: the head is read first, and only the blocks the store does not already hold are fetched.
-
-That only pays off if a returning client can tell that the pack changed without downloading it. The **asset manifest** is what makes it so. `exo assets pack --manifest dist/assets.json` writes each pack under a name derived from the hash of its own bytes (`level1.4b17e9a02c8d1f35.exoa`) and keeps a small JSON document beside the packs that says which file currently holds each logical pack:
-
-```json
-{
- "version": 1,
- "packs": {
- "level1": {
- "file": "level1.4b17e9a02c8d1f35.exoa",
- "hash": "4b17e9a02c8d1f35...",
- "byteLength": 812345,
- "blockCount": 4,
- "entries": ["images/hero.png", "data/level1.json"]
- }
- }
-}
-```
-
-A pack's URL therefore changes exactly when its bytes change, which means the pack file can be served with an immutable cache lifetime and the manifest is the one URL a client has to re-read. Read it with `loadManifest` and hand the pack to `loadContainer`:
+Invalid input or a missing installed asset type can throw synchronously. Fetching or decoding can fail later. `load()` rejects its awaitable queue; a placeholder reports failure through its state, error, and `loaded` promise. The loader also reports asynchronous failures through `onError`.
```ts
-import { cacheApiBlockStore, type Loader } from '@codexo/exojs';
-
-export const loadLevel = async (loader: Loader): Promise => {
- const manifest = await loader.loadManifest('assets.json');
-
- await loader.loadContainer(manifest.pack('level1'), { store: cacheApiBlockStore() });
+import { Asset, type LoaderScope, type Texture } from '@codexo/exojs';
+
+export const tryPortrait = async (scope: LoaderScope, path: string): Promise => {
+ try {
+ return await scope.load(Asset.type('texture', path));
+ } catch (error) {
+ console.error('Portrait unavailable', error);
+ return null;
+ }
};
```
-Re-packing after one asset changed produces a new pack name and a manifest that points at it; the blocks that did not change are already in the store, keyed by their own hash, and are never fetched again. Nothing about that is automatic for a pack loaded from a plain URL, which is the reason the manifest exists.
-
-The manifest describes packs without opening them, so `manifest.packs`, `manifest.has(name)` and `manifest.packFor('images/hero.png')` answer before a single pack byte is fetched. A name the manifest does not carry throws and names the ones it does. Pack bytes that disagree with the record - a wrong length, or a digest that is not the one stated - are an `AssetDecodeError`, the same failure any other unusable asset raises, and they are never written to the cache. How much is checked follows how much is read: the single-request path holds the whole file and verifies the digest, while the block-wise path verifies the length and rests on the content-addressed URL for the rest. The manifest itself is always read from the network, so it is the one asset an offline application cannot get.
-
-## Save data with a key-value store
-
-The cache above persists _loaded assets_. For **user save data** (settings, profiles, checkpoints) use a `KeyValueStore` instead — a small key/value surface whose backend you pick by capability:
+Catching an error should lead to an intentional fallback or a retry UI, not an unexplained blank object. If the asset is essential, let the failure reach the startup or navigation boundary instead of activating a scene that cannot function.
-- [`WebStorageStore`](/ExoJS/en/api/web-storage-store/) — `localStorage`/`sessionStorage`, JSON-serialized: small, synchronous, strings only.
-- [`IndexedDbKeyValueStore`](/ExoJS/en/api/indexed-db-key-value-store/) — IndexedDB via structured clone: large, async, stores `Blob`s and `ArrayBuffer`s natively.
-- [`MemoryStore`](/ExoJS/en/api/memory-store/) — in-memory and ephemeral, for tests and throwaway data.
+`onLoadComplete` means the batch has **settled**, including failures; it is not proof that every asset succeeded. A loading screen can display progress while the awaited queue remains responsible for success or failure. Keep subscriptions owned and remove them when the loading UI ends.
-
+## Own the lifetime, not the cache entry
-All three share one async interface, so swapping `WebStorageStore` for `IndexedDbKeyValueStore` — when a save outgrows Web Storage's ~5 MB quota or needs binary data — is a one-line change. To persist an entire scene, pair a store with [`Scene.serialize()`](/ExoJS/en/api/scene/).
+Two scopes can claim one resource. They share the acquisition and resident payload, but releasing one owner's claim does not release the other's:
-## Custom asset types (advanced)
+
-Teach the loader about a domain-specific resource by writing an `AssetType`. One value carries everything the loader needs: what the type is called, which suffixes name it, how its data is read, and how a resource is built from it.
+A scope name is a diagnostic label, not a lookup key. Creating two scopes with the same name still creates two independent owners. Child scopes have independent claims but are torn down with their parent. A destroyed scope rejects new work: `get`, `load`, `loadContainer`, and `createScope` throw instead of registering a claim that nothing could release.
-First extend `AssetDefinitions` via declaration merging so the type system knows the new type, then write the type:
+`scope.release(descriptorOrLeaf)` releases a particular claim; `scope.destroy()` releases all remaining claims and child scopes. Releasing an unclaimed asset is a no-op. A resolved object without claim identity is not necessarily a valid release argument: retain its descriptor or the owning scope instead of guessing ownership from the value.
-
+Do not call `texture.destroy()` to release an asset that a loader scope owns. Other scopes may share it. Conversely, a render texture or other GPU resource you construct yourself is not made loader-owned merely by assigning it to a sprite.
-The `id` is the type's permanent name: it appears in resource identities and in the storage namespace a persistent cache writes under, so it must survive a reload. Reverse-DNS keeps independently authored types apart.
+## Group and prefetch related assets
-A factory never fetches. It receives source data the loader already acquired, and its only outward reach is `dependencies`, which acquires other *assets*:
+Use an [asset catalog](/ExoJS/en/guide/assets/asset-catalogs/) for a named, typed group. `await scope.load(catalog)` resolves to a new map of finished values; the catalog's original leaves also become ready in place. These are related views of the same load, not the same returned object.
-| Member | What it gives you |
-| -------------------------- | ------------------------------------------------------------------ |
-| `context.options` | the options this request carried, typed by the asset type |
-| `context.source` | the source as the caller wrote it - resolve relative refs against it |
-| `context.dependencies` | loads assets this resource needs, released together with it |
-| `context.signal` | the load's cancellation signal |
+Background priority is available on catalog and catalog-leaf loads:
-Override `resourceIdentity(request)` when an option changes the resource the factory builds, and `sourceIdentity(request)` when it changes which data is acquired - omit both when the source alone identifies the asset.
-
-Install the type by listing it on an [`Extension`](/ExoJS/en/api/extension/) passed to `ApplicationOptions.extensions`. Installing is the only thing that makes it loadable, and it makes it loadable on that application alone - two applications in one process can map the same suffix to different types without seeing each other. Once installed, the type works everywhere the built-ins do:
-
-
-
-
-`heightFieldType.asset(...)` always works, and so does `Asset.type('com.example.height-field', ...)` on an application that installed the type. A bare `this.loader.load('maps/level-1.hf')` also works there once the type claims the suffix and the type system mirrors the mapping:
+```ts
+import { Assets, LoadPriority, type LoaderScope } from '@codexo/exojs';
-```ts no-check -- abbreviated AssetType sketch; the class body is elided
-export class HeightFieldAssetType extends AssetType {
- public readonly id = 'com.example.height-field';
- public override readonly extensions = ['hf'];
- public override readonly leaf = heightFieldSeamlessAdapter; // heals in place, like Texture/Sound
- /* … */
-}
+const NextLevel = Assets.from({ tiles: 'image/next-level.png' });
-declare module '@codexo/exojs' {
- interface ExtensionKindMap {
- hf: 'com.example.height-field';
- }
-}
+export const prefetchLevel = async (scope: LoaderScope): Promise => {
+ await scope.load(NextLevel, { priority: LoadPriority.Background });
+};
```
-`Assets.from('maps/level-1.hf')` is the one place this does not reach: a catalog is built with no application, so only the built-in suffixes resolve there. Name the asset with `heightFieldType.asset(...)` in a catalog instead.
-
-`leaf` decides what a catalog hands out before the payload arrives: `'ref'` (the default) a deferred `AssetRef`, a `SeamlessAdapter` the resource itself healing in place, `'none'` nothing at all. The built-in `texture` and `sound` types are the reference for a seamless resource; `json` / `text` for a deferred value.
-
-
-
-## Examples
-
-
-
-The catalog snippets on this page, end to end: a mixed `Assets.from` group, a composition, a derived catalog, two parallel queues, and a runtime-computed path.
+The caller handles a rejected prefetch promise just as it handles another load. Keep prefetch work in a scope whose lifetime is intentional; using the application loader pins the claim until application teardown. `awaitBackground()` waits for the background queue to drain, including failed items, and does not reject for those individual failures.
-
+## Residency is not offline storage
-The boot-scene snippets on this page — one bar driven by real loader signals, a retry path after failure, and a guarded hand-over to the game scene.
+A resident texture is a live runtime resource. A persistent cache holds source representations that can be decoded on a later visit. One does not imply the other. `cacheSource()` can warm configured source storage without constructing a resident resource, but it still acquires data, consumes storage, and follows the selected cache policy.
-## Where to go next
+Use [Offline and caching](/ExoJS/en/guide/assets/offline/) for persistence and connectivity, [Device variants](/ExoJS/en/guide/assets/device-variants/) for choosing a source before loading, and [Worlds and level streaming](/ExoJS/en/guide/assets/worlds-and-spawning/) for level-owned scopes. Custom asset-type authors should continue with [Authoring extensions](/ExoJS/en/guide/debugging/authoring-extensions/), not add application-specific decoders to ordinary scene code.
-That closes the runtime and asset model. The next part, [Rendering](/ExoJS/en/guide/rendering/), covers what you can put on screen — graphics primitives, sprites, text, animation, and render targets.
+
diff --git a/site/src/content/guide/audio/audio-basics.mdx b/site/src/content/guide/audio/audio-basics.mdx
index 67365c630..10acb3266 100644
--- a/site/src/content/guide/audio/audio-basics.mdx
+++ b/site/src/content/guide/audio/audio-basics.mdx
@@ -291,4 +291,4 @@ Two looping `AudioStream` tracks crossfading back and forth with `crossFade()`.
## Where to go next
-The next chapter, [Spatial audio](/ExoJS/en/guide/audio/spatial-audio/), covers 2D positional audio — how to place sounds in world space so they pan and attenuate based on the listener's position.
+Continue with [Spatial audio](/ExoJS/en/guide/audio/spatial-audio/), which covers 2D positional audio — how to place sounds in world space so they pan and attenuate based on the listener's position.
diff --git a/site/src/content/guide/audio/audio-effects.mdx b/site/src/content/guide/audio/audio-effects.mdx
index c30384911..13c4d3379 100644
--- a/site/src/content/guide/audio/audio-effects.mdx
+++ b/site/src/content/guide/audio/audio-effects.mdx
@@ -363,4 +363,4 @@ Reverb and delay filters on the SFX bus, with live wet/dry and delay-time slider
## Where to go next
-The next chapter, [Beat detection](/ExoJS/en/guide/audio/beat-detection/), covers tempo tracking and beat analysis — how to sync game logic to the rhythm of your music.
+Continue with [Beat detection](/ExoJS/en/guide/audio/beat-detection/), which covers tempo tracking and beat analysis — how to sync game logic to the rhythm of your music.
diff --git a/site/src/content/guide/audio/audio-reactive-visualization.mdx b/site/src/content/guide/audio/audio-reactive-visualization.mdx
index aa5a83a6f..42dab36d9 100644
--- a/site/src/content/guide/audio/audio-reactive-visualization.mdx
+++ b/site/src/content/guide/audio/audio-reactive-visualization.mdx
@@ -1,198 +1,81 @@
---
-title: 'Audio-reactive visualization'
-description: 'Bridge audio analysis and beat-detection state into the render pipeline via DataTexture and BeatDetector polling.'
+title: 'Audio-reactive visuals'
+description: 'Map live audio analysis to bounded visual effects, preserve resource ownership, and distinguish the audio and display clocks.'
---
-import ExamplePreview from '../../../components/ExamplePreview.astro';
import SourceSnippet from '../../../components/SourceSnippet.astro';
-import Callout from '../../../components/Callout.astro';
-
-# Audio-reactive visualization
-
-An audio-reactive scene is one where visuals move, change color, or trigger effects in response to the music or sound currently playing. ExoJS gives you two building blocks for this: [`AudioAnalyser`](/ExoJS/en/api/audio-analyser/) for per-frame spectrum data, and [`BeatDetector`](/ExoJS/en/api/beat-detector/) for rhythmic timing. You combine them inside `update` — sample what you need, map it to visual properties, and let the renderer handle the rest.
-
-## The pipeline
-
-The flow is the same for any audio-reactive setup:
-
-1. Tap a live audio source (an `AudioBus` such as `app.audio.music`, or a `Voice`) with an `AudioAnalyser` and/or a `BeatDetector`.
-2. In `update`, read spectrum values, beat envelopes, or subdivision phase.
-3. Apply those readings to drawable properties — scale, position, tint, shader uniforms, particle spawns.
-
-Both the analyser and the beat detector connect as parallel taps. They never affect the source's main audio routing, so you can attach them to anything that's already playing. They take a live source (bus, voice, `AudioNode`, or `MediaStream`) — not a `Sound`/`AudioStream` descriptor.
-
-## Building an analyser
-
-An `AudioAnalyser` wraps a Web Audio `AnalyserNode` and exposes frequency and time-domain data. You point it at a source, then call its getters each frame:
-
-
-
-You can also pass `source` as a constructor option: `new AudioAnalyser({ source: app.audio.music, fftSize: 1024 })`. Either form works; the setter is useful when you need to switch sources at runtime. To analyse a single track in isolation, keep the `Voice` from `play()` and pass that instead of the bus.
-
-## Spectrum sampling strategies
-
-The raw FFT returns N bins linearly spaced from 0 Hz to the Nyquist frequency (half the sample rate). For most visualizations this spacing is awkward — bass gets a handful of bins, treble gets hundreds.
-
-`AudioAnalyser` gives you four ways to read the spectrum, each with a byte and a float variant:
-
-| Method | Output | Use case |
-|--------|--------|----------|
-| `getSpectrum()` | `Uint8Array` (0–255 per bin) | Direct bar-graph visualizations |
-| `getSpectrumFloat()` | `Float32Array` (dBFS per bin) | Precise amplitude measurement |
-| `getSpectrumMel()` | `Uint8Array` (0–255 per band) | Perceptually-weighted display bands |
-| `getSpectrumLog()` | `Uint8Array` (0–255 per band) | Octave-uniform display (each octave gets equal visual width) |
-
-The mel and log methods accept an optional `bands` parameter (default 32) and frequency range (`fMin`/`fMax`, default 20 Hz to 20 kHz, clamped to Nyquist). The filterbanks are built once per `(bands, fMin, fMax, fftSize)` combination and cached on the analyser instance — subsequent calls at the same parameters are just a weighted sum.
-
-
-
-## Beat-driven animation: polling vs. events
-
-`BeatDetector` gives you both event-style signals and per-frame polling getters. Which one you use depends on what kind of visual you are driving.
-
-The event signals — `onBeat`, `onDownbeat`, `onBarStart`, `onTempoChange` — fire when the worklet processor detects a beat and dispatches a message to the main thread. Use them for one-shot side effects: spawning a particle burst, triggering a screen flash, advancing a sequencer:
-
-
-
-For continuous animation — something that smoothly decays between beats rather than snapping — use the polling getters inside `update`:
-
-| Getter | Returns | Description |
-|--------|---------|-------------|
-| `pulse` | `number` (0–1) | Decaying envelope. Peaks at 1 on every beat, then halves every `pulseHalfLife` seconds (default 0.15). |
-| `barPulse` | `number` (0–1) | Same shape, but resets only on downbeats and decays per `barPulseHalfLife` (default 0.3). |
-| `justBeat` | `boolean` | `true` for the visual frame(s) within `justBeatWindow` seconds of a beat onset (default 0.03). |
-| `secondsSinceLastBeat` | `number` | Elapsed time in seconds since the most recent beat. Returns 0 before the detector locks. |
-| `subdivisionPhase(n)` | `number` (0–1) | Phase within an N-subdivision of the current beat. `subdivisionPhase(4)` gives 16th-note phase. |
-
-These are all pure derivations from the detector's internal state — they do not allocate, do not fire events, and are safe to call every frame:
+import ExamplePreview from '../../../components/ExamplePreview.astro';
-
+# Audio-reactive visuals
-Tune the envelope shapes with the mutable public fields `pulseHalfLife`, `barPulseHalfLife`, and `justBeatWindow`. Smaller values give snappier responses; larger values give longer afterglow.
+An audio-reactive scene maps a live signal to a visual property. Use an `AudioAnalyser` for amplitude and spectrum, and a `BeatDetector` when the visual needs an estimated rhythmic grid. Start with a sound that already plays reliably through the intended bus or voice; analysis does not replace loading, playback, or audio unlock.
-
-`justBeat` is only `true` for a frame or two per beat (a ~30 ms window), so a frame-rate dip below ~30fps can skip the window entirely. For effects that must never drop a beat — a sound cue, a scored hit — drive them from the `onBeat` signal, which is dispatched once per beat regardless of frame rate.
-
+The analyser and detector tap a live bus, voice, node, or supported stream. They do not analyse a `Sound` or `AudioStream` asset descriptor merely because that asset has loaded. A bus tap sees the signals routed through that bus; a voice tap isolates one playback.
-## Spectrograms with DataTexture
+## Read once and update existing visuals
-When you want a scrolling spectrogram — a 2D texture that updates each frame with a new column of frequency data — use [`DataTexture`](/ExoJS/en/api/data-texture/). Its pixels live in a CPU-side typed array that you mutate directly, then upload to the GPU with `commit()` or `commitRect()`.
+The following scene expects audio already playing on the application's music bus. Without that source it correctly draws a silent spectrum; it does not start a track itself:
-`DataTexture` defaults to nearest-neighbor filtering with clamp-to-edge wrapping, which is what you want for spectrum data where bilinear filtering would corrupt sampled values.
+
-
-A scrolling spectrogram rewrites just one column per frame. `commitRect(x, y, width, height)` re-uploads that single region to the GPU — far cheaper than `commit()`, which re-uploads the entire texture every frame.
-
+`this.track` releases the analyser with the scene. The scene root owns the graphics. Playback may have a different lifetime: application-owned music can continue after this visualizer ends, while a scene-owned voice should stop with its own scene.
-A simple scrolling spectrogram:
+Read the required analysis data once per update and use it for several visual properties. Recreating an analyser, texture, filter, or particle system every frame obscures the mapping and creates unrelated work.
-
+## Choose a useful analysis scale
-For ring-buffer patterns where only the newest column changes, `commitRect(col, 0, 1, 64)` uploads just that one-pixel-wide column — cheaper than uploading the whole texture every frame.
+Raw FFT bins are linearly spaced in frequency. Logarithmic or mel-spaced bands are often more useful for a display because low-frequency structure remains visible without dedicating most bars to high frequencies.
-## A compact practical scene
+Byte-valued spectrum data is convenient for a normalized visual intensity. Floating-point spectrum values describe the analyser's decibel-domain output; they are not calibrated acoustic loudness or an exact physical amplitude measurement. Choose a transfer function, floor, and smoothing that suit the image rather than treating every sample as an absolute meter.
-Here is a complete scene that combines an analyser-driven bar graph with beat-triggered background color changes:
+Map a normalized intensity into a bounded range: a small scale change, a limited blur radius, or a capped spawn rate. Keep a base value separate from the animated offset so camera shake or pulse animation does not accumulate drift. Rate, particle lifetime, bursts, and system capacity must agree; a maximum rate alone does not establish a safe occupancy.
-
+## Rhythmic envelopes and one-shot events
-## Timing semantics
+Use `pulse`, `barPulse`, or a subdivision phase for continuous motion. Use beat signals for one-shot visual requests such as a burst or a color change. If several messages arrive between visual frames, decide whether to aggregate, coalesce, or bound them rather than spawning an unbounded backlog.
-A few things to keep in mind when working with audio-driven visuals:
+`justBeat` is a time-window predicate. Depending on frame timing, that window can be observed by zero, one, or several frames. It is not an edge-triggered event and must not directly repeat a one-shot action on every frame that sees `true`.
-- The analyser spectrum reflects the current frame's audio buffer. You sample it once per `update` call; the `AnalyserNode`'s `smoothingTimeConstant` (default 0.8) smooths between consecutive analyses.
-- Beat detector events are dispatched from the worklet processor to the main thread. They arrive asynchronously and may be arbitrarily close to or slightly behind the visual frame; the worklet's internal temporal resolution is finer than `requestAnimationFrame` cadence. The polling getters (`pulse`, `justBeat`, `subdivisionPhase`) sample the detector's most recent cached state on the main thread.
-- `justBeat` is `true` for at most one or two visual frames per beat (with the default 30ms window at 60fps). If your frame rate drops below ~30fps, you may miss a `justBeat` window. For critical beat-triggered effects, prefer the `onBeat` signal — it is dispatched per beat and delivered on the main thread.
-- Every timestamp the detector reports — `BeatInfo.audioTime`, `nextBeatTime`, the `lookahead` entries and `analysisTime` — is an `AudioContext.currentTime` value, so [`AudioOutputClock`](/ExoJS/en/api/audio-output-clock/) converts any of them straight onto the `performance.now()` timeline.
-- `analysisTime` names the newest audio the current state describes, and `analysisLatency` is how far behind the context clock that state already was when the main thread received it. The figure is measured rather than assumed and covers the analysis hop together with the worklet-to-main-thread delivery, so it is a budget rather than an exact age.
+Beat messages are asynchronous worklet-to-main-thread notifications. They preserve a different boundary from polling, but do not promise zero latency, hard real-time delivery, or a perfect beat grid. A detector's estimate is suitable for reactive presentation; rhythm-game scoring needs an explicit authoritative timing and calibration design, not an arbitrary confidence threshold.
-### Tempo confidence is not phase confidence
+Tempo confidence and phase confidence answer different questions. A stable estimated BPM can coexist with uncertain beat placement. Use that distinction to soften or disable a visual response while tracking is uncertain. See [Beat detection](/ExoJS/en/guide/audio/beat-detection/) for the detector's acquisition and event lifecycle.
-`confidence` says how sure the detector is of the **tempo**; `phaseConfidence` says how well recent onsets support the **position** of the beat grid. They come apart more often than they look like they should — a passage with a rock-solid pulse played with heavy rubato reads high confidence and low phase confidence, and so does anything the detector has had to free-run through because no onset arrived where it predicted one.
+## Upload a bounded spectrum history
-Gate anything that has to land exactly on the beat — a scored hit, a quantised trigger, a flash meant to be felt rather than seen — on `phaseConfidence`, and keep `confidence` for decisions about the tempo itself, such as whether to display a BPM readout at all:
+A `DataTexture` can store a fixed-size history without reconstructing an image every frame:
-```ts
-import { BeatDetector } from '@codexo/exojs-audio-fx';
+
-const detector = new BeatDetector();
+Pass 64 byte-valued bands, for example from `getSpectrumMel(undefined, { bands: 64 })`. The texture stores the value in its red channel. A material or filter can map that value to a color. Track the `SpectrumHistory` instance or call its `destroy` method when its owner ends.
-const shouldScoreHits = (): boolean => detector.phaseConfidence > 0.6;
-const shouldShowBpm = (): boolean => detector.confidence > 0.5;
-```
+This is a ring buffer: column positions wrap. It does not automatically present time from oldest to newest. Use `nextColumn` when sampling or rearranging the display if chronological scrolling is required.
-## Lining audio up with the frame clock
+`commitRect` transfers the changed region rather than the entire texture. That reduces uploaded data; the actual performance benefit depends on update size and upload overhead. Keep the texture dimensions and history length bounded, and measure before treating many tiny uploads as universally cheaper.
-Web Audio schedules in `AudioContext.currentTime` and your frame loop measures in `performance.now()`. The two are different clocks with different origins, and `currentTime` runs ahead of what the listener actually hears by the length of the output path. Subtracting one from the other gives a number that looks plausible and is wrong by tens of milliseconds - enough to visibly desynchronise a rhythm game.
+## Audio time is not frame time
-`AudioOutputClock` correlates them:
+Beat timestamps are expressed on the `AudioContext.currentTime` timeline. Frame callbacks use a different clock. Do not subtract an audio timestamp directly from `performance.now()`.
```ts
import { AudioOutputClock } from '@codexo/exojs';
const clock = new AudioOutputClock();
-
-/** How long from now until the listener hears the sample scheduled for `contextTime`. */
-const msUntilHeard = (contextTime: number): number => clock.contextToPerformanceTime(contextTime) - performance.now();
+const millisecondsUntilOutput = (contextTime: number): number =>
+ clock.contextToPerformanceTime(contextTime) - performance.now();
```
-Where the browser supports `AudioContext.getOutputTimestamp()`, the correlation names the sample the device is playing at this instant, so the converted time already accounts for the output path and needs no latency arithmetic of your own. Where it does not, the clock pairs `currentTime` with `performance.now()` and marks the snapshot `'estimated'` so you can tell the two cases apart:
-
-```ts
-import { AudioOutputClock } from '@codexo/exojs';
-
-const snapshot = new AudioOutputClock().snapshot();
+Where an output timestamp is available, the clock correlates the audio sample being presented with performance time. Its fallback is marked as estimated. Inspect that source instead of adding guessed latency compensation to both paths. Display latency and the application's choice of presentation frame remain outside the correlation.
-if (snapshot.source === 'estimated') {
- // No output timestamp here: the correlation leads the true output by roughly
- // `outputLatency`, which this environment may not report either.
-}
-```
+The analyser reflects an audio analysis window, not a buffer synchronized exactly to the current visual frame. Detector `analysisTime` and latency information describe the received analysis state; they do not eliminate main-thread scheduling delays.
-Display latency is deliberately outside this: only your code knows how far ahead of the photons its render loop runs, so the decision of which frame to draw a beat on stays yours.
+## Put the scene together
-## Examples
+Keep playback ownership, analysis ownership, and rendering ownership explicit. Start from [Audio basics](/ExoJS/en/guide/audio/audio-basics/), add one analyser, then map one bounded visual property. Add beat-driven bursts only after the continuous response and its timing are understood.
-
-A full audio visualisation: frequency-domain bars, time-domain waveform overlay, and per-band energy meters drawn on a 2D canvas then uploaded as a texture.
-
-
-A sprite pulses and particles burst on the `onBeat` signal while the BPM estimate updates.
-
-## Where to go next
+
-For color-oriented effects driven by audio — palette cycling, noise overlays, shader filters — see [Filters](/ExoJS/en/guide/effects/filters/). To write a custom shader that samples a spectrogram `DataTexture`, see [Custom mesh shaders](/ExoJS/en/guide/effects/custom-mesh-shaders/). If you came here before reading [Beat detection](/ExoJS/en/guide/audio/beat-detection/), that chapter covers the full event API, tempo tracking, and frequency band state in detail.
+The examples demonstrate display and effect mappings. They are not calibrated loudness meters, authoritative rhythm timelines, or equal-work CPU/GPU benchmarks. Continue with [Particles](/ExoJS/en/guide/effects/particles/) or [Filters](/ExoJS/en/guide/effects/filters/) for the visual mechanism rather than duplicating its setup inside every audio recipe.
diff --git a/site/src/content/guide/audio/beat-detection.mdx b/site/src/content/guide/audio/beat-detection.mdx
index 9fdbe99fe..1fa405c6d 100644
--- a/site/src/content/guide/audio/beat-detection.mdx
+++ b/site/src/content/guide/audio/beat-detection.mdx
@@ -230,4 +230,4 @@ The beat and tempo example also demonstrates the `tempo`, `confidence`, and `ons
## Where to go next
-The next chapter, [Audio-reactive visualization](/ExoJS/en/guide/audio/audio-reactive-visualization/), covers how to bridge the detector's output into the render pipeline — polling getters for continuous animation, spectrum data from `AudioAnalyser`, spectrograms with `DataTexture`, and building full audio-reactive scenes.
+Continue with [Audio-reactive visualization](/ExoJS/en/guide/audio/audio-reactive-visualization/), which covers how to bridge the detector's output into the render pipeline — polling getters for continuous animation, spectrum data from `AudioAnalyser`, spectrograms with `DataTexture`, and building full audio-reactive scenes.
diff --git a/site/src/content/guide/audio/spatial-audio.mdx b/site/src/content/guide/audio/spatial-audio.mdx
index 5210732f9..7b6809882 100644
--- a/site/src/content/guide/audio/spatial-audio.mdx
+++ b/site/src/content/guide/audio/spatial-audio.mdx
@@ -375,4 +375,4 @@ The same example lets you switch distance models without restarting its voice.
## Where to go next
-The next chapter, [Audio effects](/ExoJS/en/guide/audio/audio-effects/), covers the audio filter system — how to shape sound with compressors, EQs, reverb, delay, and worklet-based effects like pitch shifting and ducking.
+Continue with [Audio effects](/ExoJS/en/guide/audio/audio-effects/), which covers the audio filter system — how to shape sound with compressors, EQs, reverb, delay, and worklet-based effects like pitch shifting and ducking.
diff --git a/site/src/content/guide/debugging/authoring-extensions.mdx b/site/src/content/guide/debugging/authoring-extensions.mdx
index 2b4216d39..24312294c 100644
--- a/site/src/content/guide/debugging/authoring-extensions.mdx
+++ b/site/src/content/guide/debugging/authoring-extensions.mdx
@@ -7,7 +7,7 @@ import Callout from '../../../components/Callout.astro';
# Authoring extensions
-The [previous chapter](/ExoJS/en/guide/debugging/custom-renderers/) showed how to register a custom renderer against one running `Application`. An **extension** packages that same work — plus custom asset handlers and node serializers — into a single immutable descriptor you can publish as an npm package and drop into any project. This is exactly how the official `@codexo/exojs-particles`, `@codexo/exojs-tiled`, `@codexo/exojs-tilemap`, and `@codexo/exojs-physics` packages plug into the core.
+The [Custom renderers chapter](/ExoJS/en/guide/debugging/custom-renderers/) showed how to register a custom renderer against one running `Application`. An **extension** packages that same work — plus custom asset handlers and node serializers — into a single immutable descriptor you can publish as an npm package and drop into any project. This is exactly how the official `@codexo/exojs-particles`, `@codexo/exojs-tiled`, `@codexo/exojs-tilemap` packages plug into the core. Physics, pathfinding, and lighting are constructed directly instead; an optional package does not necessarily contribute an extension descriptor.
The model is deliberately small and **add-only**: an extension can only *contribute* capabilities (a new drawable type's renderer, a new asset type, a new serializable node). It never patches or replaces core behaviour. The core ships nothing extension-specific — an `Application` understands your drawable or asset type only once its extension is active.
@@ -176,7 +176,7 @@ export * from './public';
},
"files": ["dist/esm/", "README.md", "LICENSE"],
"peerDependencies": {
- "@codexo/exojs": "0.15.x"
+ "@codexo/exojs": "0.18.x"
},
"devDependencies": {
"@codexo/exojs": "workspace:*"
@@ -190,8 +190,8 @@ export * from './public';
ExoJS is pre-1.0, so every minor release is a **clean break** — no back-compat shims. Extensions track the core in **lockstep**:
-- Pin the peer range to the core's current minor (`"@codexo/exojs": "0.15.x"`), and publish a matching minor of your extension for each core minor.
-- Publish your extension version to move in step with the core version it targets; document the compatibility in a small table in your README (`0.15.x ↔ 0.15.x`), as the official packages do.
+- Pin the peer range to the core's current minor (`"@codexo/exojs": "0.18.x"`), and publish a matching minor of your extension for each core minor.
+- Publish your extension version to move in step with the core version it targets; document the compatibility in a small table in your README (`0.18.x ↔ 0.18.x`), as the official packages do.
- Because the id-collision check is by descriptor identity, a mismatched-version duplicate install throws loudly at registration rather than failing subtly at draw time.
## A tiny extension end to end
diff --git a/site/src/content/guide/debugging/backend-comparison.mdx b/site/src/content/guide/debugging/backend-comparison.mdx
index afd2b1dfb..a70b6aa56 100644
--- a/site/src/content/guide/debugging/backend-comparison.mdx
+++ b/site/src/content/guide/debugging/backend-comparison.mdx
@@ -1,121 +1,69 @@
---
-title: 'Backend comparison'
-description: 'Compare backend behavior and decide what to ship.'
+title: 'Backend selection and portability'
+description: 'Select a rendering backend, read measured parity evidence, and handle capabilities and device loss without assuming browser-wide guarantees.'
---
-import ExamplePreview from '../../../components/ExamplePreview.astro';
-import Callout from '../../../components/Callout.astro';
import ParityMatrix from '../../../components/ParityMatrix.astro';
+import ExamplePreview from '../../../components/ExamplePreview.astro';
-# Backend comparison
+# Backend selection and portability
-ExoJS renders through one of two backends: WebGL2 or WebGPU. The choice is automatic by default — the engine picks WebGPU when `navigator.gpu` is available, WebGL2 otherwise. You can override this with `backend: { type: 'webgpu' }` or `backend: { type: 'webgl2' }` in `ApplicationOptions`.
+ExoJS has WebGL2 and WebGPU backends behind the same high-level drawing API. Backend selection is an application-level decision. It is not a performance preset, and a browser exposing `navigator.gpu` is not proof that every required feature works on that device.
-## Selection
+## Start with automatic selection
-```ts
-import { Application } from '@codexo/exojs';
+The default `backend.type` is `auto`. Automatic selection considers WebGPU availability and deliberately selects WebGL2 for detected WebKit user agents. Explicitly requesting WebGPU bypasses that selection policy; it does not make an unavailable or failing adapter usable.
-// Auto-select (prefers WebGPU, falls back to WebGL2)
-const app = new Application();
+```ts
+import { Application, RenderBackendType } from '@codexo/exojs';
-// Pin to WebGPU — throws if unavailable
-const gpuApp = new Application({ backend: { type: 'webgpu' } });
+const app = new Application({
+ backend: { type: 'auto' },
+ canvas: { width: 800, height: 600, mount: 'body' },
+});
-// Pin to WebGL2
-const glApp = new Application({ backend: { type: 'webgl2' } });
+await app.start();
+console.log(app.backend.backendType === RenderBackendType.WebGpu ? 'WebGPU' : 'WebGL2');
```
-At any point, check `app.backend.backendType` (a [`RenderBackendType`](/ExoJS/en/api/render-backend-type/) enum with values `WebGl2` and `WebGpu`) to branch backend-specific code.
-
-
-`auto` (the default) picks WebGPU where `navigator.gpu` exists and WebGL2 everywhere else, so most projects never set `type` at all. Pinning `type: 'webgpu'` turns availability into a hard requirement — it throws on browsers without WebGPU instead of falling back.
-
-
-## Feature parity
+This starts an application without a scene so that the example isolates backend initialization. A normal project registers and starts a scene as in [Your first scene](/ExoJS/en/guide/getting-started/your-first-scene/). Handle a rejected startup at the application boundary; do not continue as though a backend exists.
-The core rendering pipeline — sprites, meshes, graphics, text, containers, masks, filters, render-targets, views, culling — works identically on both backends. The API is the same. You write one scene and it renders on both.
+Inspect `app.backend` and `app.capabilities` after startup resolves. Set `backend: { type: 'webgl2' }` or `{ type: 'webgpu' }` to test a particular path. Pinning a backend makes that path a requirement instead of an automatic choice. Changing the backend requires a new application; an example's backend switch is not an in-place mutation of the running renderer.
-### What has been measured
+## Read parity as evidence
-That claim is easy to make and hard to keep, so a conformance suite renders the same scenes through both backends in each browser and compares the frames pixel by pixel. The table below reports what it found — not what we believe.
+The conformance suite exercises specific scenes through both backends and compares their output. The matrix below is generated from the repository's evidence, including its recorded browser and release context:
-Read it as evidence, not as a support promise. A `?` means nobody has measured that combination yet; it is not a statement that the feature is broken, and it is deliberately visible rather than hidden. The scenes use a texture whose every texel encodes its own coordinates, so a matching frame proves the right texel landed in the right pixel — not merely that two images happened to look alike.
-
-The table names the release it is guaranteed as of. `release:cut` refuses to cut a version until the evidence for Chromium and Firefox was measured on the commit being released, then stamps that version onto the rows — so the guarantee is tied to a version rather than to whenever someone last ran the suite. Rows measured after a release carry no version until the next one claims them.
-
-Chromium is measured on every CI run. Firefox and Safari need a machine with a display and are measured by hand, which is why their rows carry an older date.
-
-
-`pnpm test:parity` runs the matrix on Chromium, `test:parity:firefox` and `test:parity:safari` on the others. Each run rewrites only the rows for the browser it exercised.
-
-
-
-The Safari column above shows it — `auto` already keeps Safari on WebGL2 for this reason. Stick with WebGL2 there rather than pinning `backend: { type: 'webgpu' }` for production use.
-
-
-Feature parity is a different question from cost, and the matrix above says nothing about the latter. One stress workload has been measured end to end on both backends. In the `scrolling-world` case of the engine's benchmark harness — one million static sprites spread over four times the viewport's area, with a moving camera — both backends draw the visible world in a single draw call: a large static world can keep its renderable state persistent while the camera moves, so moving the camera does not require rebuilding the visible scene from scratch. On the documented reference run (headless Chromium, NVIDIA GeForce RTX 5070 Ti, ExoJS 0.15.2, 2026-08-15) CPU-p95 measured 10.08 ms on WebGL2 and 10.45 ms on WebGPU.
-
-Read that as one dated result for one workload on one machine, not as a backend-wide guarantee and not as a frame-rate claim. CPU-p95 is the time the engine spent in the render path on the CPU in its 95th-percentile frame — not the frame's GPU time, and not a whole game frame with update, input, and physics in it. The two backends' full-frame columns come from different instruments (a hardware timer query on WebGL2, a queue-completion wall clock on WebGPU) and are not comparable one-to-one. Methodology, metric definitions, the environment metadata recorded per run, and the command that reproduces this cell live in the harness's README: [`@codexo/exojs-bench`](https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-bench/README.md).
+A matching case is evidence for that case under the recorded conditions. It is not proof for every shader, driver, device, or composition of features. An unmeasured cell is unknown, not automatically a failure. Use the recorded provenance rather than translating a browser name into a permanent support promise.
-### Known differences
+Parity and performance answer different questions. A scene can match visually and have different submission costs or GPU time on two backends. Use the [benchmark methodology](/ExoJS/en/benchmarks/) for measured workloads and [Performance](/ExoJS/en/guide/debugging/performance/) for your own application.
-Where differences exist, they are performance characteristics or rendering-path specifics, not API gaps:
+## Where portability needs attention
-| Area | WebGL2 | WebGPU |
-|------|--------|--------|
-| Sprite batching | Multi-texture batched (up to 8) | Multi-texture batched (up to 8) |
-| Particle simulation | CPU | CPU (default); optional GPU compute update path when all update modules implement `wgsl()` |
-| MeshMaterial | GLSL ES 3.00 | WGSL |
-| ShaderFilter | `glsl` source (GLSL ES 3.00) | `wgsl` source (WGSL) |
-| GPU compute | Not available | Raw `backend.device` access for compute + custom pipelines |
-| Engine-emitted debug pass labels | Not currently emitted | Emitted on key passes (e.g. `ShaderFilter pass`) |
+| Area | Portable workflow | Boundary to check |
+| --- | --- | --- |
+| Sprites, graphics, text, views | Use the high-level scene and drawing APIs. | Sampling, clipping, target formats, and device limits still matter. |
+| Custom materials and filters | Supply the shader forms required by each supported backend. | GLSL and WGSL are separate programs; TypeScript does not validate their visual equivalence. |
+| Particle simulation | Provide a usable CPU path or require the GPU path explicitly. | WebGPU compute eligibility depends on the system and its modules, not just the selected backend. |
+| Render textures and lighting | Inspect capabilities and choose supported formats. | Float renderability, resolution, and optional normal or radiance paths have distinct constraints. |
+| Raw GPU resources | Keep ownership and restoration in the custom renderer. | A raw WebGPU pipeline has no automatic WebGL2 implementation. |
-Both backends batch sprites from up to 8 different textures into a single draw call. For most projects with a single texture atlas, the batching difference is irrelevant — the practical distinction is only visible in multi-atlas scenes.
+Both backends support multi-texture sprite batching. That does not mean arbitrary sprites collapse into one draw: materials, blend state, clipping, capacity, and ordering can split work. Do not select a backend using a single texture-count rule.
-Geometric clip parity is also aligned: `RenderNode.clip` with a `Geometry` `clipShape` works on both backends for `Sprite` (default/custom material), `Mesh` (default/custom material), `Graphics`, `Text` / `BitmapText`, and `ParticleSystem`. `Rectangle` / bounds clips remain on the scissor path.
+For a `ShaderFilter`, a missing language can fail when the filter attaches to the other backend. Providing both strings avoids that omission, but each program still needs compilation and visual testing. [Custom mesh shaders](/ExoJS/en/guide/effects/custom-mesh-shaders/) explains the material boundary; [Shader files](/ExoJS/en/guide/shipping/typed-shaders/) explains build-time authoring.
-## Particles and GPU
+## Device loss and restoration
-Particle simulation (spawn, integration, module updates) runs on the CPU by default on both backends. On WebGPU, when every registered update module is GPU-eligible (implements `wgsl()`), the system compiles a composite WGSL compute shader and runs the full update pipeline on the GPU in one dispatch. This is the GPU path: simulation moves to the GPU, and the renderer reads instance data from a shared buffer — no CPU readback.
+The application exposes `onBackendLost` and `onBackendRestored`. Engine-owned resources participate in the engine's recovery path. Resources allocated directly from a raw device or context remain your responsibility: recreate pipelines, buffers, textures, and cached handles when their underlying device changes.
-On WebGL2, or when any update module lacks `wgsl()`, the system runs on CPU. The particle rendering path (instanced draw calls) is the same on both backends. Check `system.gpuMode` to know which path is active.
+For an SDK renderer, implement repeatable `connect` and `disconnect` behavior rather than keeping stale GPU handles across restoration. A recovered canvas with one missing custom effect often indicates that the effect retained an old resource. See [The renderer SDK contract](/ExoJS/en/guide/debugging/renderer-sdk-contract/).
-## Custom shaders
+## Test the deployment you intend to ship
-A [`Shader`](/ExoJS/en/api/shader/) accepts both `glsl: { vertex, fragment }` and `wgsl` source; wrap it in a [`MeshMaterial`](/ExoJS/en/api/mesh-material/) to bind uniforms and attach it to a `Mesh`. The renderer picks the appropriate language for the active backend. Provide both for cross-backend portability. [`Shader.detectUniformDrift()`](/ExoJS/en/api/shader/#methods) compares declared uniforms across languages for CI-style verification.
+Exercise startup failure, the fallback you intend to support, resize, hidden-tab return, and device loss where the test environment permits it. Test the production bundle on representative hardware, not just a development tab on the fastest machine available. Avoid silently enabling an effect whose required shader or format is absent.
-For screen-space effects, use `ShaderFilter` and give it both a `glsl` and a `wgsl` source. The uniform-value types are the same either way — only the shader language differs, and the filter picks the one the active backend needs. A filter carrying only one language throws `ShaderFilterBackendError` when it attaches to the other backend.
-
-## Direct GPU access
-
-The WebGPU backend exposes `backend.device` (`GPUDevice`), `backend.context` (`GPUCanvasContext`), and `backend.format` (`GPUTextureFormat`). You can create custom pipelines, vertex buffers, and command encoders directly — the [`custom-triangle-renderer`](/ExoJS/en/playground/?slug=custom-renderers/custom-triangle-renderer) example demonstrates this. On WebGL2, `backend.context` is also publicly available as a `WebGL2RenderingContext`, but the direct compute-style escape hatch exists only on WebGPU.
-
-## Device loss
-
-Both backends handle loss events. On WebGL2, the engine attempts context restore automatically. On WebGPU, ExoJS also attempts automatic device recovery and emits `onBackendLost` / `onBackendRestored` on the `Application`.
-
-
-The engine restores the resources it owns, but anything you allocated through direct `backend.device` access is gone after a loss. Recreate those raw pipelines and buffers in an `onBackendRestored` handler, or the scene comes back missing your custom rendering.
-
-
-## Choosing for production
-
-- **Ship with `auto`** (the default). Most users get WebGPU, older browsers get WebGL2. You write one codebase, both paths work.
-- **Pin to `webgpu`** if you depend on features only available through direct backend access (compute shaders, raw GPU pipelines) and can accept the browser-support trade-off.
-- **Pin to `webgl2`** if you are targeting a specific environment where WebGPU is unreliable or unavailable. This is uncommon — auto-selection covers this case.
-
-The [Sprite Batching Laboratory](/ExoJS/en/playground/?example=performance/backend-comparison) lets you toggle backends at runtime (press B) while keeping its seeded sprite workload the same.
-
-## Examples
-
-
-Adjust sprite count and texture diversity, then use the performance overlay. Press B to switch between WebGL2 and WebGPU when an adapter is available. The result describes this workload, not general backend performance.
-
-## Where to go next
-
-The next chapter, [Custom renderers](/ExoJS/en/guide/debugging/custom-renderers/), covers extending the render pipeline with your own passes — no-op passes, full-screen triangles, and bridging a custom renderer into the engine's frame.
+The seeded workload helps compare the two backend paths while controlling the scene. Its result is about that workload, not an overall backend winner.
diff --git a/site/src/content/guide/debugging/custom-renderers.mdx b/site/src/content/guide/debugging/custom-renderers.mdx
index 0a5086f76..9e627e36a 100644
--- a/site/src/content/guide/debugging/custom-renderers.mdx
+++ b/site/src/content/guide/debugging/custom-renderers.mdx
@@ -1,122 +1,62 @@
---
-title: 'Custom renderers'
-description: 'Extend rendering with custom passes and backend-specific logic.'
+title: 'Custom rendering'
+description: 'Choose the smallest extension point that expresses custom drawing, and keep pass ordering and GPU ownership explicit.'
---
-import ExamplePreview from '../../../components/ExamplePreview.astro';
import SourceSnippet from '../../../components/SourceSnippet.astro';
-import Callout from '../../../components/Callout.astro';
-
-# Custom renderers
-
-"Custom rendering" in ExoJS means inserting your own draw logic into the frame — either as a pass between normal draws, or as direct GPU work that bypasses the engine's renderer system entirely. The extension surface is intentionally small: two public mechanisms, plus the existing `Mesh`/`MeshMaterial`/custom shader filter primitives covered in earlier chapters.
-
-The distinction: a custom renderer controls *when* and *how* things are drawn. A [`MeshMaterial`](/ExoJS/en/api/mesh-material/) controls *what shader* an existing `Mesh` uses. A custom [`ShaderFilter`](/ExoJS/en/api/shader-filter/) controls a screen-space post-render effect on a drawable's output. You reach for custom renderers when none of those primitives fit — you need to issue draw calls at a specific point in the frame, or you need to bypass ExoJS drawable types entirely.
-
-## CallbackRenderPass
-
-[`CallbackRenderPass`](/ExoJS/en/api/callback-render-pass/) wraps an arbitrary draw callback as one [`RenderPass`](/ExoJS/en/api/render-pass/) and slots it into a [`RenderPipeline`](/ExoJS/en/api/render-pipeline/) between other passes. The callback receives the [`RenderingContext`](/ExoJS/en/api/rendering-context/) — the same high-level object your scene's `draw` method gets. For low-level draws (rendering a `Graphics` directly, immediate-mode geometry), reach through `context.backend` to the active `RenderBackend`:
-
-
-Reach for `CallbackRenderPass` for almost all custom draw work — it slots into the normal pipeline and hands you the high-level `RenderingContext`. Drop to raw `backend.device` only when no pass primitive can express what you need.
-
-
-
-
-The pipeline runs its passes in order, so the callback's draws land exactly where you place the pass — here, between the two sprite passes. Use `CallbackRenderPass` for procedural geometry (arcs, connectors, debug lines between objects), for rendering non-scene-graph content, or for inserting a filter step at a specific point in the frame.
+import ExamplePreview from '../../../components/ExamplePreview.astro';
-## Low-level backend passes
+# Custom rendering
-Beneath the high-level, context-aware pass tree sits `BackendRenderPass` — an interface for a single backend-only command. Its one method, `execute(backend)`, receives the `RenderBackend` directly (no camera, not a frame phase). Implement it when a custom pass needs raw backend access — custom shaders, backend-specific draw logic — and run it via `backend.execute(pass)`:
+Start by deciding what is missing: geometry, a shader, a post-process, a place in the frame, or a renderer for a new drawable type. These are different problems. Raw GPU access is not the default solution to all of them.
-
+| Need | First mechanism to consider |
+| --- | --- |
+| Different vertex geometry | `Mesh` with the normal rendering path. |
+| A custom shader on an existing drawable | The appropriate material and shader API. |
+| An effect over already rendered pixels | `ShaderFilter` or a frame-level filter pass. |
+| Procedural drawing between other passes | `CallbackRenderPass` in a `RenderPipeline`. |
+| A reusable renderer for a new drawable class | An SDK renderer contributed through a renderer binding. |
+| GPU work none of those paths can express | Backend-specific resources with explicit lifetime and recovery. |
-A `RenderPipeline` composes high-level `RenderPass` objects (`RenderNodePass`, `CallbackRenderPass`, nested pipelines), not `BackendRenderPass` directly. To run a `BackendRenderPass` inside a pipeline, wrap it in a `CallbackRenderPass` and call `context.backend.execute(pass)` — the bridge shown above. For most custom rendering you never need this layer; `CallbackRenderPass` is the intended escape hatch.
+Read [Custom mesh shaders](/ExoJS/en/guide/effects/custom-mesh-shaders/) before implementing a renderer merely to change a material. Read [Post-processing](/ExoJS/en/guide/effects/post-processing/) before managing offscreen targets merely to apply a filter.
-## Direct backend access
+## Insert drawing into a pipeline
-When you need full GPU control — raw pipeline creation, custom vertex buffers, compute dispatches — access `WebGpuBackend` directly through `app.backend`. This is the escape hatch: you bypass ExoJS drawable types and renderer pipelines entirely, working at the WebGPU API level:
+A `CallbackRenderPass` receives the high-level `RenderingContext` and executes where it appears in a pipeline. The surrounding passes retain their ordering:
-```ts no-check -- sketch of a caller-owned renderer; its fields are assigned in an untyped constructor
-import { WebGpuBackend } from '@codexo/exojs/renderer-sdk';
+
-class CustomTriangleRenderer {
- constructor(backend) {
- if (!(backend instanceof WebGpuBackend)) {
- throw new Error('This requires a WebGPU backend.');
- }
- this._device = backend.device; // GPUDevice
- this._format = backend.format; // GPUTextureFormat
- this._context = backend.context; // GPUCanvasContext
+Keep the callback focused on submission. Create long-lived resources outside the per-frame callback, update state during the scene's update phase, and draw that state when the pass executes. Use the context's explicit view and target mechanisms instead of relying on whichever raw backend state a previous pass left behind.
- // Create your own pipeline, vertex buffers, command encoders...
- this._pipeline = this._device.createRenderPipeline({ /* ... */ });
- this._vertexBuffer = this._device.createBuffer({ /* ... */ });
- }
+
- draw() {
- const encoder = this._device.createCommandEncoder();
- const pass = encoder.beginRenderPass({
- colorAttachments: [{
- view: this._context.getCurrentTexture().createView(),
- clearValue: { r: 0, g: 0, b: 0, a: 1 },
- loadOp: 'clear',
- storeOp: 'store',
- }],
- });
- pass.setPipeline(this._pipeline);
- pass.setVertexBuffer(0, this._vertexBuffer);
- pass.draw(3);
- pass.end();
- this._device.queue.submit([encoder.finish()]);
- }
-}
-```
+## Bridge a backend-only command
-The scene's `draw` method would call `this._triangleRenderer.draw()` instead of `context.render(sprite)`. The custom renderer owns the entire render pass and does not interact with ExoJS drawables.
+`BackendRenderPass` is a lower-level command whose `execute` method receives a backend rather than a scene-aware context. A `RenderPipeline` contains high-level `RenderPass` objects; bridge a backend command through a callback rather than putting the wrong pass type directly into the pipeline:
-Direct backend access is deliberately unabstracted — you are writing raw WebGPU code. On WebGL2 backends, a different renderer class would use `backend.context` (`WebGL2RenderingContext`) and branch by `backend.backendType`. For most projects, `MeshMaterial` + `CallbackRenderPass` + `ShaderFilter` cover custom rendering needs without reaching for raw GPU APIs.
+
-## Renderer registration
+A backend command does not automatically acquire a camera, a scene lifetime, or ownership of the application's frame. It must respect the target, view, clipping, and pass boundaries established by its caller.
-
-Build a custom renderer by subclassing the abstract base renderers from `@codexo/exojs/renderer-sdk` (`AbstractWebGpuRenderer`, `AbstractWebGl2Renderer` / `AbstractWebGl2BatchedRenderer`). The engine's concrete renderers such as `WebGl2SpriteRenderer` are internal, coupled to private data paths, and not part of the SDK surface — subclassing them will break.
-
+## Contribute a drawable renderer
-ExoJS resolves a renderer for each drawable type through the `RendererRegistry` (available from `@codexo/exojs/renderer-sdk`). You can register a custom renderer for an existing drawable type via `backend.rendererRegistry.registerRenderer(drawableConstructor, renderer)`. A renderer implements `connect(backend)`, `disconnect()`, `render(drawable)`, and `flush()` — these map to GPU resource acquisition, release, per-drawable recording, and batch submission respectively.
+Use `@codexo/exojs/renderer-sdk` for the abstract renderer bases and binding helpers. Do not subclass a concrete internal sprite or mesh renderer: its private data path is not an extension contract.
-In practice you build a custom renderer by extending one of the **abstract** base renderers from `@codexo/exojs/renderer-sdk` — `AbstractWebGl2Renderer` / `AbstractWebGl2BatchedRenderer` (WebGL2) or `AbstractWebGpuRenderer` (WebGPU) — which is exactly how the `@codexo/exojs-particles` and `@codexo/exojs-tilemap` packages build theirs. The engine's built-in *concrete* renderers (e.g. `WebGl2SpriteRenderer`) are internal: coupled to internal sprite/mesh data paths and intentionally not part of the SDK surface, so don't subclass them directly.
+A renderer connects to a backend, records compatible drawables, flushes recorded work, and disconnects to release GPU resources. The registry resolves one renderer per drawable constructor and can follow the prototype chain. Contribute a new drawable type or branch inside its renderer; do not assume that registering a second renderer for the same constructor silently replaces the first.
-Registering a custom renderer is an advanced extension point. For most custom rendering needs — procedural geometry between draws, a single custom shape that doesn't fit `Mesh` — `CallbackRenderPass` is the simpler and more intentional path.
+Package a binding in an application-selected extension as described in [Authoring extensions](/ExoJS/en/guide/debugging/authoring-extensions/). Importing the package should not mutate global registration.
-## When to use which
+## Own raw resources deliberately
-| Mechanism | Use when |
-|---|---|
-| [`Mesh`](/ExoJS/en/api/mesh/) | You need custom vertex geometry with the standard pipeline |
-| [`MeshMaterial`](/ExoJS/en/api/mesh-material/) | You need a custom vertex/fragment shader on a `Mesh` |
-| Custom shader filter (`ShaderFilter`) | You need a screen-space post-render effect on a drawable |
-| `CallbackRenderPass` | You need to issue draw calls at a specific point in the frame between other draws |
-| `BackendRenderPass` | You need a reusable backend-only command (custom shader / draw logic); bridge it into a pipeline via `CallbackRenderPass` |
-| Direct backend access | You need to bypass ExoJS entirely and work at the GPU API level |
+Raw WebGPU or WebGL2 work bypasses some high-level guarantees. A separate command encoder that clears the swapchain can overwrite the engine's frame; an unbalanced target or clip stack can corrupt later drawing. Coordinate raw work with the application's pass ordering instead of submitting an unrelated frame from inside `draw`.
-## Examples
+Recreate resources after backend loss, invalidate cached handles, and release everything on disconnect. Avoid retaining an application's lazily created pass coordinator across device recovery. Provide a supported alternative or a clear capability requirement when the custom path works on only one backend.
-
-
+The canonical [custom triangle example](/ExoJS/en/playground/?example=custom-renderers/custom-triangle-renderer) is an executable backend-specific demonstration, not a portable replacement for a material.
-The custom callback pass draws a procedural arc between composition and the HUD. Toggle the blur pass to see how a named pipeline step can be bypassed without freezing the displayed frame.
+## Validate in a composed scene
-## Where to go next
+A renderer that works alone may fail after another renderer, under a mask, inside a retained group, on the second frame, or after device loss. Test empty flushes, repeated connection and teardown, resource growth, interleaving, and the capabilities it claims to support.
-The next chapter, [Authoring extensions](/ExoJS/en/guide/debugging/authoring-extensions/), shows how to package a custom renderer — along with custom asset handlers and node serializers — into a distributable extension, exactly how the official `@codexo/exojs-particles` and `@codexo/exojs-tiled` packages plug into the core.
+[The renderer SDK contract](/ExoJS/en/guide/debugging/renderer-sdk-contract/) explains retained recording, generation changes, borrowed storage, and pass coordination. Opt into advanced reuse only when the renderer can honor those contracts; a correct conservative path is preferable to replaying stale GPU state.
diff --git a/site/src/content/guide/debugging/debugging-and-inspection.mdx b/site/src/content/guide/debugging/debugging-and-inspection.mdx
index b1ac43258..484e8de8e 100644
--- a/site/src/content/guide/debugging/debugging-and-inspection.mdx
+++ b/site/src/content/guide/debugging/debugging-and-inspection.mdx
@@ -1,275 +1,85 @@
---
-title: 'Debugging & inspection'
-description: 'Inspect scene state and runtime behavior with overlay layers, and trace filter chains and render passes with the in-engine inspector.'
+title: 'Debugging and inspection'
+description: 'Inspect visibility, hit testing, frame behavior, and filter structure while keeping diagnostic estimates separate from GPU measurements.'
---
import ExamplePreview from '../../../components/ExamplePreview.astro';
-import SourceSnippet from '../../../components/SourceSnippet.astro';
-import TryIt from '../../../components/TryIt.astro';
-import Callout from '../../../components/Callout.astro';
-# Debugging & inspection
+# Debugging and inspection
-ExoJS ships five diagnostic layers in the `@codexo/exojs/debug` optional entrypoint, all managed by `DebugOverlay`: performance, bounding boxes, hit-test, pointer stack, and the render-pass inspector covered below. These tools render as overlays on top of your running scene — no scene-logic changes, no special draw-call instrumentation, no build flags.
+Start with the smallest failing scene and one question. Is the object outside the view? Does the pointer reach the intended node? Did loading finish? Is a filter attached twice? The optional `@codexo/exojs/debug` entry point provides overlays for inspecting a running application without moving debugging logic into the scene.
-## DebugOverlay
+For an empty canvas or failed startup, begin with [Troubleshooting](/ExoJS/en/guide/shipping/troubleshooting/). The overlay itself needs a usable application and is not a replacement for handling initialization errors.
-The [`DebugOverlay`](/ExoJS/en/api/debug-overlay/) is the entry point. Create one against your application, toggle individual layers by setting their `visible` flag:
+## Attach an owned overlay
```ts
-import { Application } from '@codexo/exojs';
+import type { Application } from '@codexo/exojs';
import { DebugOverlay } from '@codexo/exojs/debug';
-const app = new Application();
-const debug = new DebugOverlay(app);
+export const attachDebugTools = (app: Application): (() => void) => {
+ const debug = new DebugOverlay(app);
-// During development — just flip a flag
-debug.layers.performance.visible = true;
-debug.layers.boundingBoxes.visible = true;
+ debug.layers.performance.visible = true;
+ debug.layers.boundingBoxes.visible = true;
-// Toggle with keyboard shortcuts (F1–F4 and F6, while canvas has focus)
+ return () => {
+ debug.destroy();
+ };
+};
```
-The overlay subscribes to `app.onFrame` and renders each visible layer. World-space layers (`boundingBoxes`, `hitTest`) render first under screen-space panels (`performance`, `pointerStack`, `renderPassInspector`). The overlay's `visible` flag suppresses all layers without changing individual visibility.
+Call the returned disposer when the development host no longer needs the tools. Hiding a layer skips that layer's drawing and collection, but a constructed overlay still owns subscriptions and shortcuts. Destroy it before final performance measurements rather than treating hidden diagnostics as a zero-cost guarantee.
-| Layer | Property | Shortcut |
-|-------|----------|----------|
-| Performance | `layers.performance` | F1 |
-| Bounding boxes | `layers.boundingBoxes` | F2 |
-| Hit-test | `layers.hitTest` | F3 |
-| Pointer stack | `layers.pointerStack` | F4 |
-| Render-pass inspector | `layers.renderPassInspector` | F6 |
+| Tool | Use it to inspect | Shortcut |
+| --- | --- | --- |
+| Performance | Frame behavior and renderer counters. | F1 |
+| Bounding boxes | The bounds the engine associates with visible content. | F2 |
+| Hit test | Interactive, hovered, and captured nodes. | F3 |
+| Pointer stack | Candidate nodes under the pointer and their ordering. | F4 |
+| Render-pass inspector | Attached filters, masks, cache flags, and an optional logical pipeline. | F6 |
-F5 is deliberately unbound — browsers reload the page on it, which would tear down the very session you are inspecting.
+Shortcuts apply while the canvas has focus. F5 remains available for the browser's reload action. The overlay releases its shortcut claims on destruction.
-While the overlay exists it claims these keys as engine input, so the browser's own defaults for them (F1's help window, F3's find bar) stay suppressed. `debug.destroy()` releases them again.
+## Visibility and input
-## Performance layer
+Use bounding boxes to check placement, anchor, scale, and the active camera before changing the renderer. A missing or surprising box narrows the investigation; it does not identify every possible cause of an invisible object. Also check loading state, opacity, masking, clipping, and whether the object is submitted from `draw`.
-The [`PerformanceLayer`](/ExoJS/en/api/performance-layer/) shows four real-time metrics in a compact panel (top-left):
+For pointer problems, enable both the hit-test and pointer-stack layers. Check which node is on top, whether a modal interaction scope limits the candidates, and whether a previous press still owns pointer capture. Screen coordinates and world coordinates are different: use the view that rendered the interactive object when converting between them.
-| Metric | Source |
-|--------|--------|
-| **FPS** | Rolling 60-sample average of frame times |
-| **Frame** | Current frame duration in milliseconds |
-| **Draws** | GPU draw calls issued this frame (`backend.stats.drawCalls`) |
-| **Nodes** | Total `RenderNode` count in the scene |
-
-A sparkline below the text shows the last 120 frames of frame-time history — a quick visual of frame-rate stability. The sparkline maxes out at 33ms (~30 FPS), so any frame that hits the top boundary is below 30 FPS.
-
-Press F1 or set `debug.layers.performance.visible = true` to enable it.
-
-## Bounding-boxes layer
-
-The [`BoundingBoxesLayer`](/ExoJS/en/api/bounding-boxes-layer/) draws colored rectangle outlines around every visible `RenderNode` with non-zero bounds. Each node's outline hue cycles by its `zIndex` — adjacent z-indices get visually distinct colors. This layer renders in world space, so boxes move and rotate with the scene:
-
-```ts
-import { DebugOverlay } from '@codexo/exojs/debug';
-
-declare const debug: DebugOverlay;
-debug.layers.boundingBoxes.visible = true; // or F2
-```
-
-Use this to debug layout issues, verify sprite bounds match expectations, or spot invisible nodes taking up space. It is the fastest way to answer "where does the engine think this node is?"
-
-
-A node that's in the tree but invisible tells you which way to look: no box means its bounds are zero (nothing to draw), while a box off in a corner means it's mis-anchored or off-screen. F2 tells the two apart instantly.
-
-
-## Hit-test layer
-
-The [`HitTestLayer`](/ExoJS/en/api/hit-test-layer/) color-codes interactive nodes based on their pointer state:
-
-- **Magenta**: interactive but idle (not hovered)
-- **Yellow**: currently hovered
-- **Cyan**: pointer-captured (being dragged or pressed)
-
-This layer renders in world space. Combined with `debug.layers.pointerStack.visible` (F4), which lists which nodes are under the cursor in a screen-space panel, you can trace the full hit-test path from pointer position to interactive node:
-
-```ts
-import { DebugOverlay } from '@codexo/exojs/debug';
+
-declare const debug: DebugOverlay;
-debug.layers.hitTest.visible = true; // or F3
-debug.layers.pointerStack.visible = true; // or F4
-```
+## Inspect filter structure, not imaginary GPU timings
-The pointer-stack panel shows up to 10 nodes under the cursor sorted by `zIndex` (topmost first), with canvas coordinates, constructor names, and an `— interactive` flag for nodes that participate in hit testing.
+The render-pass inspector walks visible nodes beneath the current scene's `root` and collects nodes that have at least one filter. Each entry contains the filter sequence, bounds, a mask flag, and a texture-cache flag. A node with only a mask and no filter is not included in that list.
-## Overlay lifecycle
+`totalPasses` is a **structural count**: the number of attached filters in the collected entries, plus one for each entry with a mask. It is not a measured hardware-pass count. It does not expand a multi-step blur or bloom, subtract cached work, or inventory all application-level passes. A `[cached]` flag describes configuration, not proof that no cache rebuild happened in the current frame.
-All debug layers live on `DebugOverlay.layers`. The overlay subscribes to application events (`onFrame`, `onKeyDown`, `onResize`) and drives layer updates and rendering. Call `debug.destroy()` when you no longer need the overlay — it unsubscribes from all events and destroys every layer.
+Use the panel to find unexpected attachments or large filtered bounds. Confirm actual work with renderer counters or a GPU capture before claiming a pass reduction. To inspect an explicit `RenderPipeline`, set it on the inspector; the resulting rows describe enabled state and nesting, not execution time.
-Debug layers have zero overhead when their `visible` flag is `false` — `DebugOverlay._onFrame` skips invisible layers entirely. You can leave the overlay constructed for a full development session without worrying about perf impact on profiling.
+The `entries` array is reused on update. Copy the values you need before retaining a history, and remember that filter objects in an entry are live objects rather than immutable serialized state.
-## Logging
+## Log a useful failure
-Separately from the visual overlays above, ExoJS ships a message-first [`Logger`](/ExoJS/en/api/logger/) in the core module — no special entrypoint required. The engine uses it internally (for example, `Application` logs unexpected failures with `source: 'Application'`), and it's available for your own code too. A ready-to-use default instance, `logger`, is exported alongside the class:
+The Core logger accepts a message and optional structured context:
```ts
import { logger } from '@codexo/exojs';
-logger.debug('Bundle "level-1" queued', { source: 'assets' });
-logger.info('Entered gameplay scene', { source: 'scene' });
-logger.warn('AudioContext resumed after a user gesture', { source: 'audio' });
-logger.error('Simulation step threw', { source: 'physics', error: new Error('physics step failed') });
-```
-
-Each call takes a `message` plus an optional options bag: `source` (rendered as `[ExoJS][source]`, or a bare `[ExoJS]` when omitted), `data` for structured context, and `error` for `error()` calls. Use `source` to identify the subsystem or class emitting the entry so your own tooling can filter by it.
-
-Severity follows `LogSeverity`: `Debug < Info < Warning < Error`. In production builds, `Debug`/`Info`/`Warning` calls never reach a sink — `Logger` checks severity at runtime and returns early — but only `Error` calls are unconditionally guaranteed to survive. The build does *not* compile the lower-severity calls out of the bundle: the `logger.debug(...)` call itself, its message string, and any `data` object you pass are still constructed and executed every time, and only discarded afterward. In development builds, a console sink is registered by default and prefixes every line with a styled `[ExoJS]` (or `[ExoJS][source]`) badge.
-
-
-Only `Error` reaches a sink in a production build — `Debug`, `Info`, and `Warning` calls are discarded by an early return inside `Logger`, not removed from the shipped code. The call and its arguments still run, so if a message or `data` object is expensive to build, construct it lazily (e.g. behind a function or your own guard) rather than assuming it gets stripped.
-
-
-To capture log entries yourself — for an in-game console, telemetry, or a custom debug panel — register a sink with `addSink`. It returns an unsubscribe function:
-
-```ts
-import { logger, LogSeverity } from '@codexo/exojs';
-
-const unsubscribe = logger.addSink((entry) => {
- if (entry.severity >= LogSeverity.Warning) {
- console.warn(entry.source, entry.message, entry.data ?? entry.error);
- }
-});
-
-// later
-unsubscribe();
-```
-
-For warnings that could otherwise repeat every frame (a stale asset reference checked in `update`, say), pass `once` — the entry is dropped after the first call for a given key, at any severity:
-
-
-
-## Inspecting the render pipeline
-
-Every filter attached to a drawable costs at least one extra render pass. A stack of three filters on a container means the container renders to an off-screen target, the first filter reads and writes a target, the second does the same, and the third composites the result — four passes for one element. When your frame budget tightens, knowing who is adding passes and why matters.
-
-[`RenderPassInspectorLayer`](/ExoJS/en/api/render-pass-inspector-layer/) (added in v0.8.3) shows you that information live, in a compact text panel overlaid on the canvas. It ships in the `@codexo/exojs/debug` optional entrypoint alongside the other debug layers.
-
-### What the layer reveals
-
-Each frame, `RenderPassInspectorLayer` walks the scene graph and collects an entry for every `RenderNode` that has at least one filter. The panel displays:
-
-- **Total pass count** across all filtered drawables (one pass per filter, plus one per mask).
-- **Per-drawable rows** showing the constructor name (`Sprite`, `Container`, `Mesh`, `Graphics`) and the drawable's bounding-box dimensions.
-- **Filter sequence** indented under each drawable, in execution order, by constructor name (`BlurFilter`, `ColorMatrixFilter`, `LutFilter`, `ShaderFilter`, …).
-- **Flags** — `[mask]` when the drawable has an active mask (mask passes add to the total), `[cached]` when `cacheAsTexture` is set (cached drawables apply filters once, not per frame).
-
-The panel does not show drawables with zero filters — they are invisible to the render-pipeline inspector because they contribute no extra passes beyond the main batch draw.
-
-### Enabling the layer
-
-`DebugOverlay` manages the inspector alongside the other layers, so the usual route is a flag or the F6 shortcut:
-
-```ts
-import { DebugOverlay } from '@codexo/exojs/debug';
-
-declare const debug: DebugOverlay;
-debug.layers.renderPassInspector.visible = true; // or F6
-```
-
-The inspector walks `app.scenes.currentScene?.root` each frame, so it sees whichever scene is currently active. It is never added to the scene graph.
-
-### Driving the layer without the overlay
-
-`RenderPassInspectorLayer` extends `DebugLayer` and can also be driven directly from `app.onFrame` — useful when you want the inspector without constructing an overlay, or when you need it on a view of your own. Import it from the debug entrypoint, construct it against the application, and render it yourself:
-
-
-
-The screen-space view swap is necessary because `RenderPassInspectorLayer` returns `'screen'` for `viewMode` and positions its text panel at absolute pixel coordinates. Without the view swap, the panel would render in the scene's coordinate system.
-
-If you only need the data and not the built-in panel, read `inspector.entries` and `inspector.totalPasses` directly — the `update` call is still required to populate the entry snapshot:
-
-
-
-The `entries` array is replaced each frame during the inspector's `update`. Keep a copy if you need to retain frame history.
-
-### Reading the panel
-
-The panel renders in the top-left corner of the screen (at x=200 to avoid overlapping the `PerformanceLayer` panel). The header line shows the total pass count for the current frame. Below it, each filtered drawable gets a row with its dimensions and flags, followed by an indented list of filters:
-
+export const reportStartupFailure = (error: unknown): void => {
+ logger.error('Application startup failed', {
+ source: 'game',
+ error: error instanceof Error ? error : new Error(String(error)),
+ });
+};
```
-Render Passes: 7
-Sprite 512x256 [cached]
- 0. BlurFilter
- 1. ColorMatrixFilter
-Container 800x600
- 0. ShaderFilter
- 1. BlurFilter
-Graphics 128x64 [mask]
- 0. LutFilter
-```
-
-This tells you: the Sprite has two filters and is bitmap-cached (filters are baked once, not re-composited each frame — no ongoing pass cost). The Container has two active filters costing two passes per frame. The Graphics has a mask (adds one pass) plus a `LutFilter` (adds another). Total: 2 + 1 + 1 + 1 = 5, plus 2 for the Container = 7.
-
-### Understanding pass counts
-
-A filter pass means the GPU renders some geometry into a temporary render target, then a second shader reads that target and produces the filtered result. The engine reuses render target memory across filter steps (the pool keeps a steady state of two allocations regardless of filter count), but each pass still consumes GPU time proportional to the drawable's bounding-box area.
-
-Common pass-reduction strategies, in order of impact:
-
-1. **Remove filters you don't need.** Every filter the panel lists is active. If a `BlurFilter` on a background element is invisible under a solid overlay, removing it saves a pass.
-2. **Enable `cacheAsTexture`** on filtered drawables that don't change every frame. The inspector shows `[cached]` when active. Cached drawables bake their filters once and skip per-frame re-application.
-3. **Consolidate filter stacks.** Two `ColorMatrixFilter` instances on the same drawable cost two passes. Most color adjustments (brightness, contrast, saturation) can be combined into a single `ColorMatrixFilter` or replaced with a custom `ShaderFilter` that applies both in one pass.
-4. **Reduce drawable size.** Filter passes render at the drawable's bounding-box resolution (ceil). A 2048×2048 sprite with a blur costs far more than a 256×256 one with the same blur strength. The panel shows the resolution each drawable renders at.
-
-### External GPU capture tools
-
-`RenderPassInspectorLayer` tells you *what* is being drawn and *how many* passes it costs. It does not show intermediate render-target contents, shader source, or exact GPU timings.
-
-For that level of detail, use external capture tools:
-
-- **[Spector.js](https://spector.babylonjs.com/)** — WebGL2 frame capture. Shows every draw call, shader source, uniform values, and render-target contents for a captured frame.
-- **Chrome DevTools WebGPU panel** — WebGPU frame capture. Shows compute and render passes, pipeline state, bind groups, and buffer contents.
-
-On WebGPU, ExoJS emits labels on key passes so capture tools display meaningful names rather than generic calls. Look for labels such as:
-
-- `ShaderFilter pass` — a `ShaderFilter` executing
-- `MeshMaterial (custom)` — a `Mesh` with an attached `MeshMaterial` drawing inside the main render pass
-- `WebGpuMaskCompositor pass` — mask composition for a drawable with an active `mask`
-
-These labels are visible in tools that surface WebGPU pass labels (for example, Chrome's WebGPU tooling).
-
-### When to reach for the inspector
-
-- **During development** — keep the inspector on while building filter stacks. You see immediately when adding a filter to a container increases the pass count.
-- **During profiling** — when a scene's frame time is higher than expected, enable the inspector to rule out (or confirm) filter pass overhead as the cause.
-- **In CI** — snapshot `inspector.entries` and `inspector.totalPasses` on a known scene to catch regressions. A PR that accidentally enables `cacheAsTexture: false` or adds an unintended filter won't change visual output but will show up as a pass-count increase.
-
-## Examples
-
-
-
-
-Drag overlapping sprites and inspect the bounding-box, pointer-stack, and hit-test overlays. Keys 1, 2, and 3 change which sprite is in front.
-
-
+Keep logging at the boundary that can add meaningful context: which scene, asset, or user action failed. Avoid repeating the same message every frame. Production severity filtering does not make expensive argument construction free; guard costly diagnostic work instead of assuming a JavaScript minifier removes it.
-A seeded sprite workload with live FPS, frame time, draw-call count, and sparkline.
+A logger sink is an external subscription. Retain and call its unsubscribe function when its owner ends. Do not log private save data, access tokens, or full user documents merely to identify a decoding failure.
-
+## Turn the observation into a regression test
-## Where to go next
+Once the failure is understood, capture the smallest useful contract: a resource releases when its scope ends, a scene navigation rejects cleanly, an interaction scope traps focus, or a render tree contains the intended filter. A structural assertion catches structural drift; a visual or GPU claim still needs the corresponding rendering evidence.
-The next chapter, [Performance](/ExoJS/en/guide/debugging/performance/), covers scene measurement — sprite stress tests, particle throughput, and how to use the performance layer to identify bottlenecks.
+Use [Performance](/ExoJS/en/guide/debugging/performance/) for measurement and [Backend selection](/ExoJS/en/guide/debugging/backend-comparison/) for capability and parity questions. Keep the test tied to the behavior rather than to the prose that happened to describe it.
diff --git a/site/src/content/guide/debugging/performance.mdx b/site/src/content/guide/debugging/performance.mdx
index b3e71cf8a..c808ee97a 100644
--- a/site/src/content/guide/debugging/performance.mdx
+++ b/site/src/content/guide/debugging/performance.mdx
@@ -1,137 +1,71 @@
---
-title: 'Performance'
-description: 'Measure scene limits with focused stress examples.'
+title: 'Measure and improve performance'
+description: 'Separate frame pacing, CPU submission, GPU work, and memory before changing a scene.'
---
import ExamplePreview from '../../../components/ExamplePreview.astro';
import SourceSnippet from '../../../components/SourceSnippet.astro';
-import TryIt from '../../../components/TryIt.astro';
-import Callout from '../../../components/Callout.astro';
-# Performance
+# Measure and improve performance
-Performance in ExoJS is about three things: how many drawables you push per frame, how many render passes they cost, and how much state change happens between draws. The engine does a lot automatically — batching sprites that share a texture, reusing render-target memory across filter passes, culling nodes outside the view — but the decisions that matter most are yours: how many sprites, how many textures, how many filters, how many particle systems.
+A slow frame may come from simulation, input handling, asset decoding, layout, rendering submission, GPU work, or garbage collection. Reducing the number of sprites is not a diagnosis. Start with a reproducible scene and a concrete symptom: sustained low throughput, occasional stalls, growing memory, or uneven frame pacing.
-## Measuring with the performance layer
+## Establish a baseline
-Before making changes, measure. The [`PerformanceLayer`](/ExoJS/en/api/performance-layer/) from `@codexo/exojs/debug` gives you FPS, frame time, draw-call count, and node count in real time:
+Run the production build on a representative target. Record the browser, device, backend, canvas resolution, pixel ratio, and workload. Warm up the scene before comparing steady-state measurements, and measure loading separately when startup is the concern.
-```ts
-import { Application } from '@codexo/exojs';
-import { DebugOverlay } from '@codexo/exojs/debug';
+The [debug overlay](/ExoJS/en/guide/debugging/debugging-and-inspection/) is useful for finding a suspect subsystem. Its frame time and draw count are not a hardware GPU timer. Disable or destroy diagnostic tools before taking the final comparison, because their subscriptions, data collection, text, and drawing are additional work.
-const app = new Application();
-const debug = new DebugOverlay(app);
-debug.layers.performance.visible = true;
-```
+Change one variable at a time and repeat the same interaction. Keep a change when it improves the measured problem without breaking behavior or making a different target worse. Use a browser profile to locate expensive JavaScript or long tasks; use backend-specific GPU instrumentation for GPU questions.
-Watch the FPS number while you add sprites, enable filters, or spawn particles. The sparkline shows frame-time stability — a flat line is good, spikes indicate intermittent work. The draw-call count tells you how many GPU-level draw calls the renderer issued this frame.
+## Read the right measurement
-
-Isolate a single change per measurement — stacking several optimizations at once hides which one actually helped, or masks one that made things worse. Reach for the overlay before you edit, not after a hunch.
-
+| Observation | What to investigate |
+| --- | --- |
+| JavaScript time rises with active actors | Update logic, physics, allocations, input processing, or layout. |
+| Submission time rises while the visible image hardly changes | Scene traversal, state changes, uploads, sorting, or missed retained reuse. |
+| Lowering render resolution helps substantially | Pixel work, target bandwidth, filters, lighting, or overdraw. |
+| Periodic long frames | Allocation bursts, decoding, synchronous work, shader compilation, or garbage collection. |
+| Memory grows across repeated scene changes | Ownership, unreleased scopes, external listeners, pools, or caches that never become bounded. |
-## Sprite batching
+CPU submission time, GPU timer queries, queue-completion wall time, and presentation frame time are different instruments. Do not subtract or rank them as though they measured the same interval. The [benchmark pages](/ExoJS/en/benchmarks/) preserve the repository's methodology and result provenance rather than copying volatile numbers into this Guide.
-Sprites that share the same texture are batched into a single draw call on WebGPU backends. On WebGL2, single-texture batching also applies. The practical consequence: 600 sprites all using one texture atlas cost far less than 600 sprites each using a different texture.
+## Keep draw work compatible
-The [Sprite Batching Laboratory](/ExoJS/en/playground/?example=performance/backend-comparison) switches between one and four otherwise identical textures while keeping positions and motion fixed. The performance layer shows how the draw-call count changes.
+Both backends batch compatible sprites, including multiple textures within the batch's capacity. An atlas can reduce texture changes, but one atlas is not a guarantee of one draw. Material changes, blending, clips, masks, targets, view changes, ordering, and buffer capacity can introduce boundaries.
-For texture-atlas workflows, use a single `Spritesheet`-sliced `Texture` and select frames via `sprite.setTextureFrame()` rather than loading separate images per sprite.
+Group compatible work when that preserves the intended image. Do not reorder transparent content simply to improve a draw counter. For a large, stable subtree, [Retained containers](/ExoJS/en/guide/rendering/retained-containers/) may avoid rebuilding work; texture caching solves a different problem by caching pixels.
-## Render passes
+## Budget pixels as well as objects
-Every filter on a drawable adds one render pass for that drawable. A container with three filters costs three passes beyond the main batch draw. The cost scales with the drawable's bounding-box area (in pixels), not the canvas size, so occluding a filtered sprite behind a solid overlay doesn't prevent the filter passes from running.
+A filter is a logical operation, not necessarily one hardware pass. Blur, bloom, masking, normal prepasses, and lighting can require intermediate targets or multiple steps. Their cost depends on covered area, resolution, format, and the work inside each shader.
-The [`RenderPassInspectorLayer`](/ExoJS/en/api/render-pass-inspector-layer/) shows exactly who is adding passes. Enable it during development when frame time rises and you suspect filter overhead:
+Start by removing work whose result is not used. Reduce an effect's working resolution where the image permits it. Cache truly stable output when that avoids repeated work, remembering that content changes invalidate the cache. Avoid keeping a large offscreen target merely because it was convenient during prototyping.
-```ts
-import { Application } from '@codexo/exojs';
-import { RenderPassInspectorLayer } from '@codexo/exojs/debug';
+Changing DPR changes backing-store area quadratically. Set a deliberate quality policy instead of assuming maximum native density is free. [Resize, DPR and the canvas](/ExoJS/en/guide/getting-started/resize-dpr-and-canvas/) distinguishes logical size from backing resolution.
-const app = new Application();
-const inspector = new RenderPassInspectorLayer(app);
-inspector.visible = true;
-```
+## Bound simulation and allocation
-The two highest-impact reductions: remove filters you don't need, and set `cacheAsTexture = true` on filtered containers that don't change every frame. For the full set of strategies with examples, see [Render pipeline debugging](/ExoJS/en/guide/debugging/debugging-and-inspection/).
+For particles, inspect the active execution path and live occupancy. A GPU-eligible system can still be expensive because of spawning, module complexity, readback, or pixel coverage. Size capacity using spawn rate, lifetime, bursts, and peak concurrency; a rate alone cannot establish that a system is safe.
-## Particle system throughput
+Reuse frequently mutated vectors, colors, and arrays on measured hot paths. Pool short-lived objects when allocation is a real bottleneck, but keep the pool bounded and reset all relevant state. `visible = false` suppresses drawing; it does not automatically stop your update loop, release a texture claim, or disable every external interaction.
-The `ParticleSystem` (from `@codexo/exojs-particles`) is designed for throughput — SoA storage, no per-particle allocations, instanced rendering. The practical limits depend on whether you're on CPU or GPU path:
+For dynamic textures, reuse the resource. Upload changed regions where that reduces transferred data, then measure the upload overhead. Repeatedly resizing a render texture can reallocate GPU resources even when the final visible size changes only slightly.
-- **CPU path** (WebGL2, or any non-GPU-eligible update module): Suitable for moderate particle counts — performance drops roughly linearly with particle count. The per-frame update cost is proportional to `aliveCount` × number of update modules.
-- **GPU path** (WebGPU, all modules GPU-eligible): The composite WGSL compute shader runs update logic on the GPU in one dispatch, writing directly into the renderer's instance buffer — no CPU readback. This path becomes attractive for large systems with many particles and GPU-eligible modules.
+## Cull with valid bounds
-Check `system.gpuMode` at runtime to know which path is active. Measure with the [`PerformanceLayer`](/ExoJS/en/api/performance-layer/) to find the right capacity target for your scene — the optimal number depends on your module count, particle lifetime, and target frame rate.
+Culling skips drawing outside the active view; it does not stop unrelated simulation. Organize large worlds into meaningful regions instead of assuming that a single huge container makes every child cheap.
-## Scene-graph churn
+A `cullArea` replaces the bounds used for the visibility check. It is a world-space rectangle, not a local rectangle automatically transformed with the node:
-
-For scenes that spawn and retire objects constantly — bullets, hit sparks, floating damage numbers — keep a fixed pool sized to your peak concurrent count and recycle nodes by flipping `visible`, rather than constructing and destroying them each frame.
-
+
-Adding and removing many children from a container each frame triggers transform-dirty propagation and bounds recomputation. For dynamic scenes where objects appear and disappear frequently, prefer:
+Keep it current when the object moves or reserve it for fixed placement. It does not replace collision or hit-test geometry. An undersized culling area can improve a counter by incorrectly hiding content.
-- `sprite.visible = false` over `container.removeChild(sprite)` — keeps the node in the tree, skips rendering.
-- Pre-allocate a pool of sprites and recycle them by toggling visibility rather than constructing/destroying.
-- Use `zIndex` only where layering is required. Mixed `zIndex` values add per-group sorting work during render-plan optimization.
+## Reproduce the result
-## Texture updates
-
-Updating a texture source (e.g. a canvas, video frame, or `DataTexture` buffer) incurs GPU upload work each frame, and may also trigger GPU re-allocation when dimensions/format change. For per-frame updates:
-- Use `DataTexture.commitRect()` for partial uploads (cheaper than full `commit()`).
-- Avoid creating new `Texture` instances per frame — reuse one texture and update its source.
-- Keep `RenderTexture` dimensions stable — `resize()` triggers framebuffer recreation.
-
-
-Allocating a fresh `Color`, `Vector`, or `Matrix` inside `update` or `draw` runs every frame and hands the garbage collector a steady stream of short-lived objects, which surfaces as periodic frame-time spikes. Allocate once and mutate in place (`color.set(...)`, `vec.set(...)`) on hot paths.
-
-
-## View culling
-
-Nodes that fall entirely outside the current `View`'s viewport are skipped by the renderer. This is automatic and per-node. The culling check is AABB-based rather than exact shape, so nodes near the viewport edge may still be rendered even if partially outside. For large scrolling worlds, this is a significant performance multiplier — only the visible subset of your scene graph incurs draw cost.
-
-### Overriding the cull check with `cullArea`
-
-The automatic check calls `getBounds()`, which for a `Container` walks every visible child and unions their bounds — for a complex `Graphics` node or a container with hundreds of children, that walk runs every frame even when the node's on-screen footprint is trivial to reason about. Set `cullArea` to a `Rectangle` and the cull check uses it directly instead of computing bounds:
-
-
-
-Two things to keep in mind:
-
-- `cullable` and `cullArea` live on `RenderNode`, so every node that draws something has them; a bare `SceneNode` is structural only, never reaches the renderer, and carries neither.
-- `cullArea` replaces `getBounds()` only in the `inView()` check — it has no effect on hit-testing, collision, or rendering, and it's ignored entirely when `cullable = false`.
-- Because the rectangle is used as-is (not transformed), a `cullArea` on a node that moves, rotates, or scales after it's set will go stale. Either recompute it when the node's position changes, or reserve it for nodes whose world placement is fixed once configured — e.g. static level decoration, or particle-heavy effects anchored to a known spawn point.
-
-## Not a checklist
-
-The most useful performance practice is measurement. Turn on the performance layer, watch the numbers, change one thing, measure again. The engine's behavior under your specific scene — your sprite counts, your texture layout, your filter stacks — matters more than any general guideline.
-
-The engine's own reproducible stress benchmarks live with [`@codexo/exojs-bench`](https://github.com/Exoridus/ExoJS/blob/main/packages/exojs-bench/README.md), whose README documents the methodology behind them — median vs. p95, warmup and timed frames, what each metric does and does not cover. Useful as a reference for measuring honestly; the numbers that decide your game still come from the `PerformanceLayer` on your own scene.
-
-## Examples
-
-
-Change the sprite count and texture diversity in a seeded workload, then read the resulting frame time and draw-call count.
-
-
-Adjust the spawn rate and watch live occupancy, frame time, and the active execution path. WebGPU uses GPU compute simulation; WebGL2 uses a smaller CPU fallback budget.
-
-
-
-## Where to go next
-
-The next chapter, [Backend comparison](/ExoJS/en/guide/debugging/backend-comparison/), covers the WebGL2 vs. WebGPU decision — how backends are selected, where feature parity exists, and where backend-specific differences remain.
+Use these demonstrations to understand a mechanism, then profile the application that will ship. Record the changed workload and environment alongside any performance claim; a faster benchmark cell is not evidence for unrelated gameplay.
diff --git a/site/src/content/guide/effects/filters.mdx b/site/src/content/guide/effects/filters.mdx
index 010aa268d..6aee38578 100644
--- a/site/src/content/guide/effects/filters.mdx
+++ b/site/src/content/guide/effects/filters.mdx
@@ -300,7 +300,7 @@ A filter transforms a drawable's rendered pixels — it operates on the 2D outpu
- Use a `MeshMaterial` for per-vertex effects: displacement, custom lighting, procedural geometry.
- Use both together: a `MeshMaterial` on the mesh, plus a filter on the mesh's parent container.
-The next chapter, [Custom mesh shaders](/ExoJS/en/guide/effects/custom-mesh-shaders/), covers attaching a `MeshMaterial` in detail.
+Continue with [Custom mesh shaders](/ExoJS/en/guide/effects/custom-mesh-shaders/), which covers attaching a `MeshMaterial` in detail.
## Examples
@@ -326,4 +326,4 @@ The reflection captures the moving scene into a texture, flips it, and applies a
## Where to go next
-The next chapter, [Particles](/ExoJS/en/guide/effects/particles/), covers the data-oriented particle system — spawn modules, update modules, distributions, and CPU/GPU auto-routing.
+Continue with [Particles](/ExoJS/en/guide/effects/particles/), which covers the data-oriented particle system — spawn modules, update modules, distributions, and CPU/GPU auto-routing.
diff --git a/site/src/content/guide/effects/lighting.mdx b/site/src/content/guide/effects/lighting.mdx
index a3d7835fe..511276601 100644
--- a/site/src/content/guide/effects/lighting.mdx
+++ b/site/src/content/guide/effects/lighting.mdx
@@ -1,373 +1,87 @@
---
title: 'Lighting'
-description: 'Light a 2D scene with lights that are scene nodes, normals nobody authored, and shadows read out of the world you already described.'
+description: 'Choose a lighting model, register lights and occluders, and manage normal maps, frame passes, and quality as distinct concerns.'
---
+import SourceSnippet from '../../../components/SourceSnippet.astro';
import ExamplePreview from '../../../components/ExamplePreview.astro';
-import Callout from '../../../components/Callout.astro';
# Lighting
-Most 2D projects that could have shadows ship without them, and the reason is bookkeeping rather than technique: an engine that asks you to draw a silhouette per object is asking for work nobody budgeted. `@codexo/exojs-lighting` starts from the opposite end. A light is a scene node, so a torch parents to the player and follows it. A surface gets its normals from its own silhouette, so art that was never authored for lighting still reacts to it. And what blocks light is read out of a description you already have — physics colliders, a tile layer, a sprite's alpha.
+`@codexo/exojs-lighting` offers three different ways to shade a 2D scene. They share light nodes and registration concepts, but they are not interchangeable quality levels that produce the same picture at different speeds.
-> **Note:** lighting ships as an official ExoJS extension package. Install it alongside the core:
-> ```sh
-> npm install @codexo/exojs @codexo/exojs-lighting
-> ```
+Construct the chosen lighting system directly and register it with the scene. There is no `lightingExtension` descriptor to add to application options.
-There is no extension to register. A lighting system is an ordinary system you add to a scene.
-
-## Setup
-
-```ts
-import { Color, Scene } from '@codexo/exojs';
-import { LightmapLighting } from '@codexo/exojs-lighting';
-
-class CaveScene extends Scene {
- private lighting = new LightmapLighting(this.app, { ambient: new Color(20, 22, 34) });
-
- override init(): void {
- this.systems.add(this.lighting);
- }
-}
-```
-
-`ambient` is the baseline every lit fragment receives regardless of any light — `255` per channel means "unlit areas keep their full albedo", and a dark blue is the usual night. It is read every frame, so fading from day to night is one tween on a colour.
-
-Register the system with the registry that ticks **after** the code moving your lights. `app.systems` runs its update phase before the active scene's, so a system registered there sees lights the scene has not moved yet; `scene.systems` is usually what you want.
-
-## Three renderers, one vocabulary
-
-The scene describes what emits and what blocks. Which system you construct decides how that becomes pixels, and nothing else changes between them — the same lights, the same occluders.
-
-| | `ForwardLighting` | `LightmapLighting` | `RadianceLighting` |
-| ----------------------- | ---------------------------------- | --------------------------------------------------- | --------------------------------------------------- |
-| Where light is computed | inside the sprite fragment stage | in a target of its own, multiplied over the frame | the same target, filled by transporting radiance |
-| Normal mapping | per material, on `LitMaterial` | per drawable, through a prepass | no |
-| Shadows | no | yes, soft, from registered occluder sources | yes, with a penumbra that follows the source's size |
-| Light count | capped by `maxLights` (default 64) | uncapped | uncapped, and free: the cost is per probe |
-| Extra passes | none | two, a third with normals | four to nine, depending on the view |
-| Cost per light | a loop iteration per lit fragment | the fill of its own radius | none — the field costs what the screen costs |
-
-`LightmapLighting` and `RadianceLighting` light the frame the application drew, so the application is their first argument: they read its frame, install their passes in its frame slot, and follow its surface when it resizes. `ForwardLighting` shades inside the sprite stage, so it is the one that can be built without one — `new ForwardLighting({ maxLights: 16 })`.
-
-Whichever you built, the running renderer answers for itself, which is what a status line or a debug overlay reads:
-
-```ts
-import { Lighting } from '@codexo/exojs-lighting';
-
-declare const lighting: Lighting;
-
-lighting.quality; // 'forward' | 'lightmap' | 'radiance'
-```
-
-`Lighting` is the base the three share, so it is the type to take when a function accepts any of them. Pick the class whose properties the scene needs — `ForwardLighting` for normal maps on a `LitMaterial`, `LightmapLighting` for shadows and an uncapped light count, `RadianceLighting` for light that spreads.
-
-### Light that spreads
-
-`RadianceLighting` fills the same light field from a chain of radiance cascades. Light propagates from what emits instead of falling off inside each light's radius, so a lamp lights the room it stands in, a wall between two rooms leaves the second one dark, and a source with a size casts a penumbra that widens with distance.
-
-```ts
-import { Color } from '@codexo/exojs';
-import { PointLight, RadianceLighting } from '@codexo/exojs-lighting';
-import type { Application } from '@codexo/exojs';
-
-declare const app: Application;
-
-const lighting = new RadianceLighting(app, { ambient: new Color(8, 8, 14) });
-
-// Under `radiance` a light's `softness` sets the SIZE of the source, which is
-// what decides how soft the shadows it casts are.
-lighting.add(new PointLight({ radius: 300, intensity: 3, softness: 0.4 }));
-```
-
-Importing the class is what links the cascades, so a bundle that never constructs one never carries them. Its tuning sits beside the rest — `probeSpacing`, `cascades`, `interval`, all optional and all derived from the surface by default.
-
-It needs a device that can render into float targets and is refused at construction where it cannot run, so you never get it by accident.
-
-
- A wall the field lit gives part of that light off again in its own colour, one frame later - `bounce` sets how much, and `0` switches it off. A `SunLight` is the sky every unblocked ray ends in, a `SpotLight` emits across its cone, and the fields reach a margin past the picture (`fieldMargin`), so what stands just outside it still shadows and lights what is in it.
-
-
-
- Within roughly five times a source's own size the cascades are resolving it with the few directions their coarsest levels have, and its disc is rasterised at the light field's resolution. Moving the lamp by less than a texel redistributes light there — around a tenth of the arriving brightness per quarter texel, seen as a shimmer on the lamp's own halo rather than anywhere it lights. Past that radius it settles below what eight bits can express.
-
-
-
- `forward` is not the low setting and `lightmap` is not the high one. One shades inside the sprite stage and can therefore reach a per-material normal map; the other shades a field of its own and can therefore carry shadows and any number of lights. Neither is a downgrade of the other.
-
-
-## Lights are nodes
-
-A light is a `RenderNode` that emits rather than draws. It inherits the transform, parents to whatever carries it, and every field is an ordinary property — so the engine's tweens animate a light with no lighting-specific animation concept.
-
-```ts
-import { Color } from '@codexo/exojs';
-import { Lighting, PointLight } from '@codexo/exojs-lighting';
-import type { Application, Container } from '@codexo/exojs';
-
-declare const app: Application;
-declare const lighting: Lighting;
-declare const player: Container;
-
-const torch = lighting.add(new PointLight({ radius: 320, color: new Color(255, 180, 120), intensity: 1.4 }));
-
-player.addChild(torch);
-// A flicker is an ordinary tween on an ordinary property - there is no
-// lighting-specific animation concept to learn.
-app.tweens.create(torch).to({ intensity: 1.8 }, 0.4).start();
+```sh
+npm install --save-exact @codexo/exojs @codexo/exojs-lighting
```
-`add` returns the light, so creating, parenting and registering it is one expression. Registering the same light twice shades it once; destroying a registered light unregisters it.
-
-Four shapes:
+## Choose the model
-```ts
-import { Color } from '@codexo/exojs';
-import { Lighting, LineLight, PointLight, SpotLight, SunLight } from '@codexo/exojs-lighting';
+| Model | Use it for | Important boundary |
+| --- | --- | --- |
+| `ForwardLighting` | Per-sprite material lighting, including authored normals. | Each lit fragment considers the active lights; the light count is capacity-bounded. It does not cast the screen-space shadows of the other models. |
+| `LightmapLighting` | Lights and registered shadows over a composed frame. | It owns frame passes and offscreen targets. Normal mapping uses registered surfaces in a prepass. |
+| `RadianceLighting` | Light propagation and source-sized penumbrae through occluding geometry. | It samples a radiance field, needs a renderable float target, and has different visual and cost characteristics. It does not use the lightmap normal prepass. |
-declare const lighting: Lighting;
+Start with the simplest model that produces the required image. A large number of lights is not free in any model: collection, geometry, field resolution, transport, and pixel work still cost time even when there is no fixed light-count cap.
-// Equal in every direction, falling off to nothing at its radius.
-lighting.add(new PointLight({ radius: 260 }));
+## Build a frame-lit scene
-// A cone along the node's own rotation - aiming a spot is rotating it.
-lighting.add(new SpotLight({ radius: 400, angle: 35, coneSoftness: 0.3 }));
+This example needs no external images:
-// A segment: falloff is measured from the nearest point on it, so the pool of
-// light is a capsule. Neon tubes, light strips, lasers.
-lighting.add(new LineLight({ length: 120, radius: 160, color: new Color(120, 200, 255) }));
-
-// A direction and no position. Reaches everything the camera can see, falls off
-// nowhere, and its shadows are parallel.
-lighting.add(new SunLight({ intensity: 0.8 })).setRotation(-35);
-```
+
-A line light's `radius` is the distance from the **segment**, so it reaches `length / 2 + radius` along its own axis and `radius` across it. A sun's `height` is a slope rather than a length, because a source at no particular distance has no other meaning for one.
+`LightmapLighting` and `RadianceLighting` need the application host. Construct them in `init`, not in a field initializer that reads `this.app` before attachment. Scene systems update after scene code has moved its lights, so the lighting system sees that frame's positions.
-Shapes are deliberately not extensible. A shape is instance data a light-pass shader evaluates, and opening it up means either exposing that shader's structure or accepting a draw call per shape.
+The scene root owns the light node; registering it with `lighting.add` does not transfer node ownership. The scene system registry owns the lighting system and its frame-pass lifetime. The light node emits but does not draw a visible lamp image by itself.
-### Cookies
+## Keep emitters and occluders separate
-Every light takes an optional `cookie` texture — the cheapest large visual win here.
+A rendered wall does not automatically block light. Register an occluder source that describes the intended blocking geometry. Sources can read physics colliders, occupied tile cells, a sprite's alpha silhouette, a mesh outline, or an explicit polygon.
-```ts
-import type { Texture } from '@codexo/exojs';
-import { Lighting, PointLight } from '@codexo/exojs-lighting';
-
-declare const lighting: Lighting;
-declare const windowCross: Texture;
-
-lighting.add(new PointLight({ radius: 320, cookie: windowCross }));
-```
-
-The texture's full `0..1` maps onto the light's own bounding square, so the pattern turns with a cone light and scales with the radius — it is fixed to the lamp, not to the world. It is multiplied into the light, so a transparent part of the cookie casts nothing and an opaque white one changes nothing.
-
-
- A cookie travels with its light. That is right for a torch with a cut-out over it and wrong for a window, whose bars belong to the wall: move a light wearing a window and the bars slide across the floor with it. Keep such a light still and animate its `intensity` instead. Anchoring a pattern to the world is a projection rather than a cookie, and is not built.
-
-
-Lights sharing a cookie share a draw. A scene with three distinct cookies costs three draws rather than one — still one draw per texture, never one per light. `forward` ignores cookies: it shades inside the sprite stage, where a texture per light cannot be reached in one draw.
-
-## Normals nobody has to author
-
-Most 2D projects have no normal maps, and a lighting system that looks bad without them is a lighting system nobody switches on. So normals are an upgrade, never an entry fee: a surface without them is lit as a plane rather than left black.
-
-Where they come from depends on which renderer is shading.
-
-Under `forward`, they are a **material** binding:
-
-```ts
-import type { Sprite, Texture } from '@codexo/exojs';
-import { AlphaNormals, Lighting, LitMaterial, NormalMap } from '@codexo/exojs-lighting';
-
-declare const lighting: Lighting;
-declare const crate: Sprite;
-declare const hero: Sprite;
-declare const heroNormals: Texture;
-declare const crateTexture: Texture;
-
-// Lit as a plane - no map, and not black.
-crate.material = new LitMaterial({ lighting });
-
-// An authored tangent-space map.
-hero.material = new LitMaterial({ lighting, normals: new NormalMap(heroNormals) });
-
-// Derived from the texture's own alpha, once at load.
-crate.material = new LitMaterial({ lighting, normals: new AlphaNormals(crateTexture) });
-```
+Use geometry the project already owns when it matches the desired shadow. A tree canopy and its trunk may need different visual and shadow shapes; that is a reason for a separate occluder, not a missing flag on `Sprite`.
-Every sprite drawn with a given material shares its map — in practice one material per atlas — and the map must have the same layout as the albedo atlas, frame for frame. Rotation and mirroring are handled in the shader.
+Alpha and mesh extraction have boundaries. A render texture is not a synchronously readable alpha image. An animated atlas can reuse a silhouette per frame, but changing pixels inside the same frame rectangle is not the same cache key. A deforming mesh or video texture does not automatically become a newly traced outline every frame.
-The canonical input convention is **OpenGL**: green above the midpoint means the normal leans towards the top of the image, blue points out of the sprite plane, and a flat texel is `(128, 128, 255)`. This is ExoJS's own choice, not a universal standard: most authoring tools can write either convention and several — Substance's mesh bakers among them — default to DirectX, so check what your exporter is set to. A map authored the other way up is declared rather than edited:
+Use the lighting debug views to inspect the collected occluders and their rasterized mask before increasing shadow quality. If geometry is absent or thinner than the field can resolve, a higher light intensity does not repair it.
-```ts
-import type { Texture } from '@codexo/exojs';
-import { NormalMap } from '@codexo/exojs-lighting';
-
-declare const fromMax: Texture;
-
-const normals = new NormalMap(fromMax, { convention: 'directx' });
-```
-
-The setting travels with the source into both the `forward` shader and the `lightmap` prepass, and costs no texture copy and no per-frame readback. There is no auto-detection and no backend-dependent default.
-
-
- Three things are easy to run together: the channel convention (which way up green is), the texture's own orientation (which way up the image is), and this engine's y-down coordinates (which is why a normal leaning towards the top of its image leans towards local `-y`). Flipping the image does not fix a DirectX map, and declaring the convention does not flip the image.
-
-
-Under `lightmap` there is no per-fragment surface to bind to: the renderer multiplies a frame that was already drawn. A **normal prepass** puts one back. Register a drawable and the renderer draws its normal map, at the drawable's own place and orientation, into one attachment the light shader then reads:
-
-```ts
-import type { Sprite, Texture } from '@codexo/exojs';
-import { Lighting, NormalMap } from '@codexo/exojs-lighting';
-
-declare const lighting: Lighting;
-declare const crate: Sprite;
-declare const crateNormals: Texture;
-
-lighting.normalsFrom(crate, new NormalMap(crateNormals));
-```
-
-
- The attachment's alpha is coverage, and where it is zero the light lands with no `N dot L` term at all — which is exactly how the renderer behaved before the prepass existed. Switching normals on cannot darken anything that did not ask for them, and a scene that registers none pays for no attachment and no pass.
-
-
-The drawable's own texture supplies that coverage, so a silhouette claims a surface and the empty corners of its quad do not. What it inherits from every screen-space normal buffer: one normal per pixel, so overlapping surfaces resolve to the topmost.
-
-## Shadows you do not model
-
-Nothing in this package has a `castsShadow` flag. A flag on a drawable would put lighting vocabulary on a class with no lighting concern, and it would tie the shadow silhouette to the sprite's shape — which is wrong often enough that a tree casts the shadow of its trunk, not of its canopy.
-
-Instead, what blocks light is a **source**, and the useful sources read descriptions you already have:
-
-```ts
-import type { Sprite } from '@codexo/exojs';
-import { AlphaOccluder, Lighting, PhysicsOccluder, PolygonOccluder, TilemapOccluder } from '@codexo/exojs-lighting';
-import type { OccluderPhysicsWorld, OccluderTileLayer } from '@codexo/exojs-lighting';
-
-declare const lighting: Lighting;
-declare const world: OccluderPhysicsWorld;
-declare const walls: OccluderTileLayer;
-declare const tree: Sprite;
-
-// You already have colliders, so you already have shadows.
-lighting.occludeFrom(new PhysicsOccluder(world, { staticOnly: true }));
-
-// Follows the chunk streamer, so an infinite map streams its shadows.
-lighting.occludeFrom(new TilemapOccluder(walls));
-
-// A sprite's own silhouette, traced once at load.
-lighting.occludeFrom(new AlphaOccluder(tree));
-
-// The escape hatch, and the right answer whenever the shadow outline is not
-// the drawn one.
-lighting.occludeFrom(
- new PolygonOccluder([
- { x: 0, y: 0 },
- { x: 64, y: 0 },
- { x: 64, y: 96 },
- ]),
-);
-```
-
-`PhysicsOccluder` and `TilemapOccluder` take structurally typed arguments, so this package depends on neither the physics nor the tilemap package: a project without them pulls in nothing, and a project with a collision layer of its own can feed shadows from that instead.
-
-`softness` is a property of the light, in `0..1`, and the two renderers mean different things by it. Under `lightmap` it is **filter width**: the light stays a point and the shadow term is averaged over a band of its angular shadow row, up to three percent of a full turn. The edge widens, but it widens with distance from the *light* rather than from the wall, and it does not behave like a shadow cast by a source of that size. Under `radiance` it is **source size**: the emitter is given a width, and the penumbra follows from the geometry — it grows with the distance between the wall and what the shadow falls on.
-
-Neither adds a pass. Under `lightmap` the filter samples every bin under its kernel and spends between 7 and 23 texture fetches per shadowed fragment doing it, which also bounds the kernel at ten bins either side — three percent of a turn at the default `shadowResolution`, and proportionally less as that rises.
-
-
- `AlphaOccluder` reads pixels, and a `RenderTexture` has none this side of the GPU. It returns an empty source rather than a wrong outline, and a development build says which of three cases it was. If you need both a live surface and its outline, draw into an `HTMLCanvasElement` and wrap that in a `Texture`.
-
-
-### How a shadow is computed
-
-Every light gets one row of a shadow map. For a point, cone or line light that row is **polar**: for each of `shadowResolution` angular bins around the light, the distance to the nearest occluding edge. A sun has no centre to measure angles from, so its row is **linear**: one bin per strip across the light's direction, holding how far along the light the nearest occluder in that strip sits.
-
-The rows are built on the CPU from the segments the sources collected and uploaded as one texture; the light shader turns a fragment's own direction into a bin and compares. That shape is chosen so the lights stay in a single instanced draw — a shadow pass per light would break the batch the renderer exists for.
-
-## Emission
-
-A `LitMaterial` takes an `emissive` multiplier: how much light the surface emits of its own, as a multiple of its albedo.
-
-```ts
-import type { Sprite } from '@codexo/exojs';
-import { Lighting, LitMaterial } from '@codexo/exojs-lighting';
-
-declare const lighting: Lighting;
-declare const lava: Sprite;
-
-lava.material = new LitMaterial({ lighting, emissive: 2.4 });
-```
-
-It is added to the light term rather than to the colour, so emission scales the albedo the way a light does — a black pixel emits nothing however high it is set, and a transparent one stays transparent instead of glowing through its own alpha. Values above `1` push the surface past what a light could produce, which is what a `post` filter keyed on a threshold is there to catch.
-
-## Filters over the shaded frame
-
-`post` is a filter chain over what the system produced, run as one pass in `app.framePasses`. A bloom belongs here rather than on a node, because it reads the light the system accumulated — including the parts no single node drew.
-
-```ts
-import { BloomFilter, type Application } from '@codexo/exojs';
-import { Lighting } from '@codexo/exojs-lighting';
-
-declare const app: Application;
-
-const lighting = new LightmapLighting(app, { post: [new BloomFilter({ threshold: 0.9 })] });
-```
-
-It needs `app` in either renderer, and a chain passed without one is refused at construction rather than quietly ignored. Under `lightmap` the composite writes an off-screen target in the light target's own format and the chain reads that, so a threshold above `1.0` still has something to find — the light target is `rgba16f` wherever one can be rendered into, and `lighting.hdr` reports whether it is.
-
-## Seeing what the renderer sees
+
-```ts
-import { Lighting } from '@codexo/exojs-lighting';
+## Normal maps
-declare const lighting: Lighting;
+Forward lighting reads normals from a `LitMaterial`; lightmap lighting uses `normalsFrom` to register a drawable and its normal source. Without a registered normal source, the lightmap path lights that surface without the normal prepass contribution. A normal map changes surface response; it does not create occluding geometry.
-lighting.debug = 'light'; // the accumulated light field, without the scene's colours
-lighting.debug = 'normals'; // the prepass normals, encoded the way a normal map is
-lighting.debug = 'occluders'; // the silhouettes the sources collected, over the scene
-lighting.debug = 'mask'; // the same silhouettes rasterised at the light field's own resolution
-lighting.debug = null;
-```
+The default authored convention is OpenGL-style tangent space: red points right, green points toward the top of the image, and blue points out of the sprite plane. For a DirectX-style asset, declare `new NormalMap(texture, { convention: 'directx' })`. Flipping the green channel is not the same operation as flipping the texture vertically, and neither changes the engine's y-down world coordinates.
-`mask` is the one the GPU-resident paths read: it says whether a wall is thick enough to be seen at the light field's own resolution, which is what a cascade ray samples where an occluder is a drawable rather than an outline.
+Use the same atlas layout for albedo and normals. A material binding is shared by the sprites using that material, so a second atlas generally needs its own corresponding material. `AlphaNormals` derives surface detail from a silhouette; it cannot reconstruct interior detail that the alpha channel does not contain.
-The `occluders` view is the one that explains the feature: it draws what the shadows are actually being cast from, which is usually the fastest way to find out that a source is reading the wrong thing.
+The lightmap normal prepass stores one normal per covered pixel. Overlapping registered surfaces resolve by registration order within that prepass, not by an inferred full scene ordering. Test layered surfaces rather than assuming that a single normal buffer describes every overlap.
-## Cost
+
-Forward lighting costs `fragments x active lights`. With everything on screen lit and many overlapping lights, the fragment stage becomes the bottleneck well before the CPU does — measure before raising `maxLights` into the dozens on a full-screen scene.
+## Cookies and shadow softness
-The lightmap renderer costs the fill of each light's own radius, plus two full-screen passes and a third when normals are registered. `lightResolution` (default `0.5`) sets the light target's density: light is low-frequency, so half resolution is hard to tell apart and costs a quarter of the fill.
+A cookie is a mask carried by a light. Its coordinates follow the light's bounding square, rotation, and size. It is not a world-anchored projector: moving a window-shaped light also moves its pattern. Forward lighting does not support that cookie path.
-The radiance renderer costs neither of those: it costs the probe grid. Every level of the chain holds the same number of texels, and the number of levels follows the view's own diagonal, so the whole field is between four and nine full-screen-ish passes however many lights are in it. `probeSpacing` (default `2` light-field texels) is the knob that moves that cost, and halving it quadruples the grid.
+`softness` means different things in the two shadowed models. Lightmap softness filters angular shadow information around a point-like source. Radiance softness changes source size, from which the sampled penumbra follows. Do not compare equal numeric values as though they specified the same physical width.
-Shadows cost the visible occluding edges times the lights that can see them, per frame. That is bounded by collecting only the region the visible lights jointly reach, by emitting only boundary edges — a hundred-tile corridor is four segments, not four hundred — and by caching whatever does not change.
+
-## Examples
+## Radiance quality and temporal behavior
-
-
+Radiance settings control field sampling, directional coverage, and the represented region. Start with the default field, then vary one quality parameter while moving lights and the camera. Inspect thin walls, near-source regions, screen edges, and overlapping emitters; a still image does not reveal temporal sampling artifacts.
-Walls that hand over the rectangle they are drawn as, a cross whose outline is traced from its alpha, and a softness slider — with nothing in the scene declaring that it casts a shadow.
+Bounced light uses prior-frame information. Setting `bounce` to zero removes that contribution; it does not turn radiance into forward lighting. Test the temporal response your scene can tolerate instead of describing a sampled field as an exact light-transport solution.
-
-One lamp, two rooms and a doorway, with a switch between `lightmap` and `radiance` on the same scene. Watch the far room: under the light quads it is whatever the lamp's radius reaches minus a shadow, and under the cascades it is dark except for the wedge coming through the opening. The softness slider is the lamp's own size under `radiance` — widen it and every penumbra in the scene widens with it — and the width of an angular filter under `lightmap`, which is not the same quantity. Pause the motion and set a phase to compare the two renderers at the same instant; they are different transport models and will not agree pixel for pixel.
-
-
-
-
-Window bars and leaf shade as cookies on the lights themselves, a neon tube pooling in a capsule, and a sun with no position throwing parallel shadows.
+## HDR, filters, and ownership
-
-
+`lighting.hdr` reports whether the active path accumulates with headroom above one. Lightmap lighting can fall back to an `rgba8` target where its float target is unavailable; that clips earlier and changes what a thresholded bloom can detect. Radiance has its own float-target requirement. Inspect capabilities rather than assuming that one model's fallback applies to another.
-Cobbles that nobody authored a normal map for: each hands the system its own silhouette, and a prepass draws the derived normals where the stone sits.
+A post filter operates on the shaded frame. More exposure is not tone mapping, and a bloom does not supply an sRGB/linear color-management pipeline by itself. Keep source color interpretation, lighting accumulation, and display conversion conceptually separate.
-## Where to go next
+The lighting system owns its renderer resources. Light nodes, occluder sources, the host, normal sources, and filters supplied by the caller retain their own ownership. Track or dispose caller-created resources at their actual lifetime boundary; do not destroy a shared loader texture to remove one light.
-[Post-processing](/ExoJS/en/guide/effects/post-processing/) covers the frame slot the lightmap renderer installs into, which is also how you would write a lighting system of your own — `app.framePasses.addPass(myPass)` hands a pass the finished frame and lets it write the canvas, with no agreement with this package at all.
+Use the [Lighting API](/ExoJS/en/api/lighting/) for exact registration and capability contracts and [Performance](/ExoJS/en/guide/debugging/performance/) to measure the complete effect rather than a fixed pass-count slogan.
diff --git a/site/src/content/guide/effects/particles.mdx b/site/src/content/guide/effects/particles.mdx
index 04c57dce6..65f4a7922 100644
--- a/site/src/content/guide/effects/particles.mdx
+++ b/site/src/content/guide/effects/particles.mdx
@@ -1,277 +1,75 @@
---
title: 'Particles'
-description: 'Spawn and tune particle systems for environmental and reactive effects.'
+description: 'Build a bounded emitter, understand local-space simulation and CPU/GPU routing, and give the system a clear lifetime.'
---
-import ExamplePreview from '../../../components/ExamplePreview.astro';
import SourceSnippet from '../../../components/SourceSnippet.astro';
-import Callout from '../../../components/Callout.astro';
+import ExamplePreview from '../../../components/ExamplePreview.astro';
# Particles
-`ParticleSystem` is a `Drawable` that manages thousands of animated sprites with data-oriented performance. Instead of creating and destroying individual `Sprite` instances per particle, the system stores particle state in parallel typed arrays (Struct-of-Arrays) and mutates them in bulk. This keeps the work per-particle extremely lean — no allocations, no GC pressure, no per-sprite transform tree overhead.
-
-> **Note:** `ParticleSystem` ships as an official ExoJS extension package. Install `@codexo/exojs-particles` alongside `@codexo/exojs`:
-> ```sh
-> npm install @codexo/exojs @codexo/exojs-particles
-> ```
-
-The mental model: you register modules that describe how particles *spawn*, what they *do over their lifetime*, and what happens when they *die*. The system calls those modules each frame against its channel storage. You never write a per-particle `update` loop.
-
-## Setup
-
-Register the extension when creating your Application:
-
-```ts
-import { Application } from '@codexo/exojs';
-import { particlesExtension } from '@codexo/exojs-particles';
-
-const app = new Application({ extensions: [particlesExtension] });
-```
-
-## Construction
-
-A particle system needs a texture and a capacity:
-
-```ts
-import { Texture } from '@codexo/exojs';
-import { ParticleSystem } from '@codexo/exojs-particles';
-
-function createParticles(particleTexture: Texture): ParticleSystem {
- return new ParticleSystem(particleTexture, { capacity: 4000 });
-}
-```
-
-`capacity` (default 4096) is the maximum number of particles the system can have alive at once. It's fixed at construction — the backing typed arrays are allocated immediately.
-
-The system is a `Drawable`, so it has a position, rotation, scale, tint, blend mode, and participates in the scene graph like any sprite:
-
-```ts
-import { BlendModes } from '@codexo/exojs';
-import { ParticleSystem } from '@codexo/exojs-particles';
-
-declare const system: ParticleSystem;
-
-system.setPosition(400, 500);
-system.setBlendMode(BlendModes.Additive);
-```
-
-Particle positions are local to the system — setting the system's position moves the whole emitter.
-
-## Spawn modules
-
-A spawn module creates new particles each frame. Two built-in spawners cover the common cases:
-
-**`RateSpawn`** — continuous emission at a configurable rate (particles per second):
-
-```ts
-import { Vector } from '@codexo/exojs';
-import { ConeDirection, Constant, RateSpawn, Range } from '@codexo/exojs-particles';
-
-system.addSpawnModule(new RateSpawn({
- rate: new Constant(180), // 180 particles / second
- lifetime: new Range(0.6, 1.4), // random lifetime in seconds
- velocity: new ConeDirection(-Math.PI / 2, Math.PI / 5, 70, 180),
- scale: new Constant(new Vector(0.35, 0.35)),
-}));
-```
-
-**`BurstSpawn`** — named bursts at scheduled times, with optional looping:
-
-```ts
-import { Vector } from '@codexo/exojs';
-import { BurstSpawn, ConeDirection, Constant, Range } from '@codexo/exojs-particles';
-
-const burst = new BurstSpawn({
- schedule: [{ time: 0, count: 100 }], // 100 particles at t=0
- lifetime: new Range(0.5, 1.2),
- velocity: ConeDirection.omni(80, 240), // full 360° spread
- scale: new Constant(new Vector(0.4, 0.4)),
-});
-system.addSpawnModule(burst);
+Use a `ParticleSystem` for many short-lived elements that share an emission and simulation model: sparks, smoke, rain, trails, or decorative motion. Use ordinary sprites when each object needs independent gameplay identity and behavior.
-// Re-trigger the burst schedule from t=0
-burst.reset();
-```
+`@codexo/exojs-particles` supplies the simulation and rendering integration. Add `particlesExtension` to the application; importing the classes alone does not install their renderer.
-Every spawn config property that takes a value — `lifetime`, `position`, `velocity`, `scale`, `rotation`, `rotationSpeed`, `tint`, `textureIndex` — accepts a `Distribution` rather than a fixed value. Distributions are sampled per-particle at spawn time.
-
-## Distributions
-
-Distributions let each spawned particle get a different value. The most commonly used:
-
-| Distribution | What it produces |
-|---|---|
-| `Constant(value)` | The same value every time |
-| `Range(min, max)` | Uniform random number in `[min, max]` |
-| `VectorRange(xMin, xMax, yMin, yMax)` | Independent uniform random per axis |
-| `ConeDirection(angle, halfAngle, minSpeed, maxSpeed)` | Velocity vector within a directional cone |
-| `ConeDirection.omni(minSpeed, maxSpeed)` | Full 360° omnidirectional velocity |
-| `BoxArea(minX, maxX, minY, maxY, mode)` | Random point in an axis-aligned box — `'volume'` (default) fills the area, `'edge'` sticks to the perimeter |
-| `CircleArea(centerX, centerY, radius, mode)` | Random point in a circle — `'volume'` (default) fills the disk with uniform area density, `'edge'` sticks to the circumference |
-| `LineSegment(x0, y0, x1, y1)` | Random point uniformly distributed along a line segment |
-| `Curve(keys)` | Piecewise-linear spline evaluated over a particle's lifetime normalised progress (0..1) |
-| `ColorGradient(keys)` | Same as `Curve` but interpolates `Color` values |
-
-`Curve` and `ColorGradient` are `LifetimeFunction` rather than `Distribution` — they're evaluated with a normalised lifetime `t` in 0..1, not sampled at spawn. They're typically used with update modules, not spawn modules.
-
-## Update modules
-
-Update modules mutate particle state each frame. Every built-in update module that works on both backends declares a `wgsl()` contribution — when a WebGPU backend is active and every registered update module is GPU-eligible, the system auto-compiles a composite WGSL compute shader and runs the full update pipeline on the GPU in a single dispatch. When any module lacks a `wgsl()` contribution, or when running on WebGL2, the system falls back to CPU. You don't configure this — it's automatic.
-
-
-GPU mode compiles all modules into one composite shader on the first `update()`, which locks the list. Add every force, drag, fade and color module up front — you cannot append one once the system has stepped.
-
-
-
-A system takes the WGSL compute path when the backend is WebGPU and every update module is GPU-eligible; otherwise it runs the identical API on the CPU. Read `system.gpuMode` to confirm which path it took.
-
-
-The frequently-used update modules:
-
-```ts
-import { Color } from '@codexo/exojs';
-import {
- AlphaFadeOverLifetime,
- ApplyForce,
- ColorOverLifetime,
- ColorGradient,
- Curve,
- Drag,
- ScaleOverLifetime,
- Turbulence,
-} from '@codexo/exojs-particles';
-
-// Constant acceleration (gravity, wind)
-system.addUpdateModule(new ApplyForce(0, 240));
-
-// Speed-based drag
-system.addUpdateModule(new Drag(0.1));
-
-// Fade alpha over lifetime (requires a Curve)
-system.addUpdateModule(new AlphaFadeOverLifetime(
- new Curve([{ t: 0, v: 1 }, { t: 1, v: 0 }])
-));
-
-// Full color interpolation over lifetime
-system.addUpdateModule(new ColorOverLifetime(
- new ColorGradient([
- { t: 0, color: new Color(255, 200, 100, 1) },
- { t: 1, color: new Color(0, 0, 0, 0) },
- ])
-));
-
-// Animated scale
-system.addUpdateModule(new ScaleOverLifetime(
- new Curve([{ t: 0, v: 0.5 }, { t: 0.3, v: 1.2 }, { t: 1, v: 0.1 }])
-));
-
-// Procedural noise-based motion
-system.addUpdateModule(new Turbulence(30, 0.01));
+```sh
+npm install --save-exact @codexo/exojs @codexo/exojs-particles
```
-Modules can be added and removed at any time, including while particles are in flight — the next `update()` rebuilds whatever the change invalidated. On the GPU path that is the compute program alone: the particles keep the state the device has been integrating, so live tuning does not restart the effect.
-
-The one change that cannot preserve them is adding a module without a `wgsl()` implementation to a running GPU system. That moves the simulation to the CPU, which holds no copy of what the device computed, so the system clears its live particles rather than continuing from stale values.
-
-Other available update modules: `RotateOverLifetime`, `VelocityOverLifetime`, `AttractToPoint`, `RepelFromPoint`, `OrbitalForce`, `ColorOverSpeed`. The API reference documents each one's constructor options and GPU eligibility.
-
-## Death modules
-
-A death module fires once per particle when its lifetime expires. The only built-in death module is `SpawnOnDeath`:
+## Start with a bounded emitter
-
+This complete example uses the built-in white texture rather than an external asset:
-`SpawnOnDeath` forwards the dying particle's position to the child system's spawn, so each child burst appears at the parent's death location.
+
-A custom death module receives that same information as a `ParticleDeathContext` — position, velocity, rotation, scale, colour and timing at the moment of death:
+The scene's system registry advances and destroys the particle system. `draw` submits it explicitly. Do not also call `update` manually when the registry already advances it.
-
+`RateSpawn` creates particles over time. Spawn modules establish initial attributes; update modules change live particles in registration order. The example gives each particle a two-second lifetime, launches it upward, applies downward acceleration, and reduces its scale over its lifetime.
-The context is a snapshot, not a view into the system: it is the same on both backends, stays valid for the whole callback, and carries no slot index because the slot may already hold a different particle by the time the callback runs. Delivery is exactly once per expired particle, but not necessarily in the frame it expired — a GPU-simulated death arrives with its readback, typically one frame later. Readbacks overlap, so frames that each report deaths do not queue behind one another; when the device falls far enough behind, deaths wait on the GPU and arrive with a later batch, still in the order they happened. Exactly-once holds while those waiting deaths fit the system's capacity; past that the excess is dropped instead of stalling the frame, and a development build warns once per system.
+The normal emitter produces about 160 concurrent particles at steady state before considering bursts or timing effects. Capacity is 512, so this example leaves headroom. That calculation is specific to its rate and lifetime, not a general capacity recommendation.
-## Per-frame loop
+## Choose a space and units
-`ParticleSystem` is a `Drawable` — it renders itself when you call `context.render(system)` in `draw`. Call `system.update(delta)` in your scene's `update`:
+Particle positions are local to the system. Position the system in the scene, then emit relative to its origin. Supplying world coordinates for every particle while also translating the system applies that placement twice.
-
+Velocity and acceleration follow your scene's coordinate units per second and per second squared. Direction modules such as `ConeDirection` use radians; do not pass a scene node's degree rotation without converting it. In a y-down scene, an angle of `-Math.PI / 2` points upward.
-The update loop runs spawn modules, advances particle state (velocity integration, elapsed time), runs update modules, compacts dead particles, and — in GPU mode — uploads dirty slots and dispatches the compute shader. `render()` draws the system as a single instanced draw call, regardless of particle count.
+A system's ordinary bounds do not describe the whole moving cloud, so particle culling is disabled by default. Enable it only when you can maintain a valid world-space `cullArea` that covers the effect. Pixel snapping on the system does not snap each independently simulated particle.
-## Channels and manual emission
+## CPU and GPU are execution paths
-Particles are addressed by named channel, never by raw slot. A module receives the channels it needs and indexes them itself:
+On WebGL2, simulation uses the CPU path. On WebGPU, GPU routing also depends on the update modules, render mode, and backend/device attachment. Inspect `gpuMode` after the system has been attached and updated; selecting WebGPU alone is not proof that this particular effect is using compute.
-
+The GPU path keeps integrated state on the device. Do not treat a retained CPU-side position array as authoritative for a live GPU simulation. A custom CPU-only update module makes the system ineligible for that path. A custom render mode can also require CPU execution.
-The channels are `position`, `velocity`, `scale` (each `.x` / `.y`), `rotation` (`.angle` / `.speed`), `timing` (`.elapsed` / `.lifetime`), `color` (packed `0xAABBGGRR`) and `frame`. Each is the simulation's own storage, so writing moves the particle. Indices `[0, particles.count)` are the range worth visiting; `particles.isAlive(i)` skips the holes a GPU-mode system can leave behind.
+The default render mode is shared by the implementation. A custom render mode supplied to a system is owned by that system; do not give the same owned mode instance to two independently destroyed systems.
-To emit a particle yourself, ask the system for one:
+## Change modules deliberately
-```ts
-import { ParticleSystem } from '@codexo/exojs-particles';
+Modules can be added and removed after the first update. On an eligible GPU path, changing the module set rebuilds the relevant compute program. A change that forces an already running GPU system onto the CPU cannot reconstruct its integrated state from stale CPU values: live particles are cleared instead.
-declare const system: ParticleSystem;
+Treat that transition as a visible effect restart, not a seamless backend migration. Prefer stable module composition with parameter changes for continuously running effects, and exercise editor or quality-setting changes that alter eligibility.
-const particle = system.emit();
+A module order is part of the effect. A later absolute scale or color module can overwrite an earlier value; adding another module is not always equivalent to multiplying another influence into the result.
-if (particle) {
- particle.position.set(120, 40);
- particle.velocity.set(0, -80);
- particle.lifetime = 2;
-}
-```
-
-`emit()` returns `null` at capacity. Every field starts at its default — origin, no velocity, unit scale, no rotation, opaque white, frame 0, one second of life — so you write only what you vary. The returned writer is a cursor onto the emitted particle: the next `emit()` rebinds it, so fill it before emitting again.
+## Death hooks and gameplay
-`system.clearParticles()` resets the system to zero live particles, `system.liveCount` is the range that can hold them, `system.aliveCount` counts the live ones, and `system.gpuMode` reports whether the compute path is active.
+Death modules are useful for secondary visual effects. CPU and GPU death reporting have different timing and buffering costs. GPU reporting involves bounded asynchronous data transfer; do not use it as an unqualified, same-frame gameplay authority.
-
- Channel values are true where the simulation runs. On the GPU path only the compute shader advances them, so outside an update module or a render mode the CPU copy still holds the spawn values. That is why emission, not mutation, is the supported way in — and why a death module receives a snapshot rather than a slot.
-
+Bound secondary emissions so one burst cannot recursively create an unbounded effect. Setting a spawn rate to zero stops future rate-based spawning; existing particles continue until they expire or are explicitly cleared. Destroying the system ends its lifetime rather than merely fading its current population.
-## GPU auto-routing
+## Own textures and systems separately
-The decision is per-system and automatic. When:
-1. A `WebGpuBackend` is active
-2. Every registered update module implements `wgsl()`
+A texture acquired through `this.loader` remains scene-loader-owned. The particle system uses it but is not permission to destroy a resource shared by another scope. Conversely, destroying a texture claim does not stand in for releasing a caller-owned system or custom render mode.
-...the system compiles a composite WGSL compute shader that integrates position, velocity, rotation, and every module's dynamic behavior in one dispatch, writing directly into the renderer's instance vertex buffer. No CPU readback in the steady state. The `gpu-particles` example lets you vary the emission rate and observe the live count and execution path.
+Register the system with the scope that should update it. A scene-owned registry follows scene pause and teardown; an application-owned registry outlives an individual scene. Keep that choice explicit for effects that span navigation.
-If conditions aren't met (WebGL2 backend, or any module without `wgsl()`), the system runs on CPU with the same API. Your scene code is identical — no branching, no backend checks.
+## Measure the complete effect
-## Examples
+Count, lifetime, module complexity, fill area, blend mode, readback, and target resolution all contribute to cost. `liveCount` is a slot-range high-water mark on the GPU path and can include holes; use `aliveCount` when you need actual live occupancy, accounting for the work of reading it.
-
-A single upward emitter with gravity and fade — `RateSpawn` + `ApplyForce` + `AlphaFadeOverLifetime`.
-
-
-
-
-A bonfire effect with additive blending — `RateSpawn` with random position and upward velocity, plus `ColorOverLifetime` from ember-orange to transparent black.
-
-## Where to go next
+
-The next chapter, [Post-processing](/ExoJS/en/guide/effects/post-processing/), covers scene-wide multi-pass rendering — how to combine `RenderTexture` targets with filter chains for bloom, trails, and composited color grading.
+The GPU demonstration uses a different fallback budget on WebGL2; it is a capability demonstration, not an equal-work benchmark. See [Performance](/ExoJS/en/guide/debugging/performance/) for controlled comparisons and the [`ParticleSystem` reference](/ExoJS/en/api/particle-system/) for exact module and lifetime contracts.
diff --git a/site/src/content/guide/effects/post-processing.mdx b/site/src/content/guide/effects/post-processing.mdx
index 990fbfcf2..1b502fab0 100644
--- a/site/src/content/guide/effects/post-processing.mdx
+++ b/site/src/content/guide/effects/post-processing.mdx
@@ -147,4 +147,4 @@ A composable frame built once from a `RenderPipeline`: the world renders off-scr
## Where to go next
-The next chapter, [Custom mesh shaders](/ExoJS/en/guide/effects/custom-mesh-shaders/), covers attaching custom GLSL or WGSL shaders to `Mesh` drawables — the geometry-space complement to the screen-space filter and post-processing techniques covered here.
+Continue with [Custom mesh shaders](/ExoJS/en/guide/effects/custom-mesh-shaders/), which covers attaching custom GLSL or WGSL shaders to `Mesh` drawables — the geometry-space complement to the screen-space filter and post-processing techniques covered here.
diff --git a/site/src/content/guide/getting-started/project-structure.mdx b/site/src/content/guide/getting-started/project-structure.mdx
deleted file mode 100644
index c644e8d00..000000000
--- a/site/src/content/guide/getting-started/project-structure.mdx
+++ /dev/null
@@ -1,78 +0,0 @@
----
-title: 'Project structure'
-description: 'Find your way around a create-exo-app project: the entry point, scenes, assets, and how main.ts wires an Application to a Scene.'
----
-
-import SourceSnippet from '../../../components/SourceSnippet.astro';
-import NextStep from '../../../components/NextStep.astro';
-import Callout from '../../../components/Callout.astro';
-
-# Project structure
-
-A `create-exo-app` project is a standard Vite + TypeScript app. The `minimal` template gives you the smallest layout that still separates startup from scene code:
-
-```txt
-my-game/
-├─ index.html # mounts the app, loads src/main.ts
-├─ package.json # @codexo/exojs + Vite scripts
-├─ tsconfig.json # strict TypeScript config
-├─ vite.config.ts # dev server and build config
-├─ public/
-│ └─ assets/ # static files served as-is (images, audio, fonts)
-└─ src/
- ├─ main.ts # entry point: create the Application, start a Scene
- └─ scenes/
- └─ MainScene.ts # your first scene
-```
-
-Two files hold the code you will edit most: `main.ts` and the scene under `src/scenes/`.
-
-## The entry point
-
-`src/main.ts` is where the app starts. It creates one [`Application`](/ExoJS/en/api/application/), mounts its canvas, and starts a [`Scene`](/ExoJS/en/api/scene/):
-
-
-
-The application owns runtime configuration — canvas size, clear color, render backend, and the frame loop. `app.start(scene)` hands control to a scene and begins ticking. `canvas.mount` places the canvas; in a real layout you place it wherever your page needs it.
-
-## The scene
-
-`src/scenes/MainScene.ts` is the scene `main.ts` starts. It sets up state in its constructor, updates it each frame, and draws it:
-
-
-
-`update(delta)` advances state — here, rotating a box — and `draw(context)` renders it. `delta` carries the elapsed time since the last frame, so motion stays frame-rate independent. The [Your first scene](/ExoJS/en/guide/getting-started/your-first-scene/) chapter builds a scene like this from scratch.
-
-## Where assets go
-
-Files under `public/assets/` are served as-is and addressed by path at runtime — for example `loader.load('assets/hero.png')`. The path's extension tells the loader what type to produce, so no import of `Texture` is needed just to load it. The [Loading and resources](/ExoJS/en/guide/assets/loading-and-resources/) chapter covers the loader in detail.
-
-
-`public/` is the folder the dev server serves from, not part of the URL. A file at `public/assets/hero.png` is loaded as `assets/hero.png` — keeping the `public/` prefix in the path gives you a 404 at runtime.
-
-
-## Scripts
-
-The template's `package.json` defines the standard Vite scripts:
-
-```sh
-npm run dev # start the dev server with hot reload
-npm run build # produce a production build in dist/
-npm run preview # serve the production build locally
-```
-
-The other templates add more structure on top of this same shape: `game-starter` splits player logic into `src/objects/` and adds a game-over scene, and `audio-reactive` adds an analyser-driven scene. The entry point and scene model stay the same.
-
-
-Start from an empty scene and add a texture, a sprite, and per-frame motion.
-
diff --git a/site/src/content/guide/getting-started/setup.mdx b/site/src/content/guide/getting-started/setup.mdx
index cfd05f9f9..cd3e83b22 100644
--- a/site/src/content/guide/getting-started/setup.mdx
+++ b/site/src/content/guide/getting-started/setup.mdx
@@ -1,84 +1,92 @@
---
-title: 'Setup'
-description: 'Create a typed ExoJS project with create-exo-app, run the dev server, and produce a production build.'
+title: 'Setup and project layout'
+description: 'Create a Vite and TypeScript project, choose a starter, locate its scene code, and build it for production.'
---
-import Callout from '../../../components/Callout.astro';
import NextStep from '../../../components/NextStep.astro';
-# Setup
+# Setup and project layout
-The fastest way to start is `create-exo-app`. It scaffolds a Vite + TypeScript project that already imports `@codexo/exojs`, runs a scene, and is ready for `dev` and `build`.
-
-## Create a project
-
-Run the scaffolder and follow the prompts:
-
-```sh
-npm create exo-app@latest my-game
-```
-
-Then install dependencies and start the dev server:
+`create-exo-app` creates a Vite + TypeScript project with an ExoJS scene already running. Use `minimal` for the shortest learning path:
```sh
+npm create exo-app@latest my-game -- --template minimal
cd my-game
npm install
npm run dev
```
-The dev server prints a local URL. Open it and you'll see a running scene with hot reload.
+Open the local URL printed by Vite. Keep the development server running while editing the scene. Use a Node version supported by the generated project's tooling; contributing to the ExoJS repository itself requires Node 24.
-## Choose a template
+## Choose a starting point
-`create-exo-app` ships three templates. Pass one with `--template`, or pick it interactively:
+Omit `--template` to use the interactive picker, or choose one explicitly:
-| Template | Starts with |
-|----------|-------------|
-| `minimal` | One scene drawing and animating a single shape — the smallest real project. |
-| `game-starter` | A player object, a game scene, and a game-over scene with restart. |
-| `audio-reactive` | An analyser-driven scene that turns sound into a live spectrum. |
+| Template | Starting point |
+| --- | --- |
+| `minimal` | One scene and one visible animated object. |
+| `game-starter` | Keyboard-controlled gameplay, player code, and a game-over scene. |
+| `platformer` | Side-scrolling movement, physics bodies, camera follow, and jump handling. |
+| `top-down` | Tilemaps, physics, and click-to-move pathfinding, with procedural or Tiled input. |
+| `ui-app` | A settings screen using the Core UI widgets. |
+| `audio-reactive` | Shapes and animation driven by an audio analyser. |
-```sh
-npm create exo-app@latest my-game -- --template game-starter
+The specialized templates include more systems; they are starting projects, not prerequisites for learning the engine. Template availability follows the version of `create-exo-app` you run. This Guide describes the repository's current template set, which may be ahead of an older installed release.
+
+## Project layout
+
+The minimal starter separates application startup from scene behavior:
+
+```text
+my-game/
+ index.html
+ package.json
+ tsconfig.json
+ vite.config.ts
+ public/
+ assets/
+ src/
+ main.ts
+ scenes/
+ MainScene.ts
```
-The [Project structure](/ExoJS/en/guide/getting-started/project-structure/) chapter walks through what each file does.
+`src/main.ts` creates the application, registers the scene class, mounts the canvas, and awaits `app.start(MainScene)`. `src/scenes/MainScene.ts` holds the objects, frame updates, and drawing. The next chapter explains both files using the maintained template source.
-## Build for production
+Keep asset files under `public/assets/` when you want them served unchanged. `public/` is not part of the URL: `public/assets/hero.png` is requested as `assets/hero.png`. A loader `basePath` can supply the common directory; do not repeat it in individual asset paths. Deployment under a subdirectory needs the base-path treatment in [Deployment](/ExoJS/en/guide/shipping/deployment/).
-Vite produces a static bundle you can host anywhere:
+## Production build
```sh
-npm run build # output in dist/
-npm run preview # serve the production build locally
+npm run build
+npm run preview
```
-The [Deployment](/ExoJS/en/guide/shipping/deployment/) chapter covers hosting, base paths, and assets.
-
-## Add ExoJS to an existing project
+`build` produces the static application in `dist/`. `preview` serves that build locally; it is a way to check the output, not a production hosting service. Verify the production build as well as the development server, especially asset URLs and capability-dependent code.
-If you already have a bundler-based project, install the runtime directly:
+## Add ExoJS to an existing application
```sh
-npm install @codexo/exojs
+npm install --save-exact @codexo/exojs
```
-The package ships as ESM with TypeScript declarations, so any modern bundler (Vite, esbuild, Rollup, webpack, Parcel) picks up both the code and the types. ExoJS renders into a regular `HTMLCanvasElement`; create one yourself or let `Application` create it for you:
+Use the package's ESM entry point from your bundler. TypeScript declarations are included. Pin compatible exact versions for any official runtime packages you add.
+
+A canvas can be supplied or created by the application:
```ts
import { Application } from '@codexo/exojs';
-// Pass an existing canvas...
-const canvas = document.querySelector('#scene')!;
-const app = new Application({ canvas: { element: canvas } });
+const canvas = document.querySelector('#scene');
+if (canvas === null) {
+ throw new Error('Missing #scene canvas.');
+}
-// ...or let the application create one and mount app.element yourself.
+const app = new Application({ canvas: { element: canvas } });
```
-
-`app.element` is the active `HTMLCanvasElement` the runtime renders into, or `null` when the surface is an `OffscreenCanvas`. Append it wherever your layout needs it, or let `canvas.mount` do it.
-
+This configures an application; it does not start a scene. Register and start one as shown next. For a generated canvas, use `canvas.mount` to place it in the DOM. `app.element` is the HTML canvas, or `null` for an offscreen surface. A framework host must also destroy the application when that host is permanently removed; the [React guide](/ExoJS/en/guide/integrations/react/) covers the maintained React boundary.
-
-See where the entry point, scenes, and assets live in a generated project.
+
+Draw and animate an object before introducing asset loading.
diff --git a/site/src/content/guide/getting-started/what-is-exojs.mdx b/site/src/content/guide/getting-started/what-is-exojs.mdx
index ed4d86134..7b240acfd 100644
--- a/site/src/content/guide/getting-started/what-is-exojs.mdx
+++ b/site/src/content/guide/getting-started/what-is-exojs.mdx
@@ -1,57 +1,52 @@
---
title: 'What is ExoJS?'
-description: 'A short tour of what ExoJS is, where it fits, how a project is shaped, and how to read this guide.'
+description: 'Decide whether ExoJS fits your project and learn the application, scene, and documentation model.'
---
-import Callout from '../../../components/Callout.astro';
-import SourceSnippet from '../../../components/SourceSnippet.astro';
import NextStep from '../../../components/NextStep.astro';
# What is ExoJS?
-ExoJS is a TypeScript-first, zero-dependency 2D runtime for browser games and interactive apps.
+ExoJS is a TypeScript-first 2D runtime for browser games and interactive applications. It provides rendering, scenes, input, audio, UI, and asset loading; optional packages add systems such as physics, tilemaps, particles, lighting, and pathfinding.
-It gives you the core pieces of a small engine — scenes, sprites, graphics, input, audio, effects, render targets, and a frame loop — while staying close to regular web development. You can use it from JavaScript or TypeScript, render into a canvas, and keep project structure in code.
+Use it for a canvas-based game, visualization, tool, or interactive surface. It is code-first, not a visual game editor. A mostly DOM-based application can keep its existing UI framework and let ExoJS own only the canvas. The [React integration](/ExoJS/en/guide/integrations/react/) provides that boundary for React applications.
-## Where ExoJS fits
+## The model in one minute
-Use ExoJS when you want to build a browser-based 2D game, visualization, toy, editor, or interactive scene with code.
+An [`Application`](/ExoJS/en/api/application/) owns the canvas, rendering backend, frame loop, and application-level services. A [`Scene`](/ExoJS/en/api/scene/) organizes one screen or activity: it loads resources, sets up objects, updates state, and chooses what to draw.
-It gives you engine-level building blocks without forcing a particular app framework or editor workflow. If you're building a mostly DOM-based interface, keep using your UI framework of choice. If you're building an interactive canvas surface, ExoJS can own that part.
+The scene's `root` is a hierarchy of drawable objects. Adding a child establishes hierarchy; `draw(context)` explicitly renders it. The separate `scene.ui` layer is a screen-fixed overlay and is rendered automatically. Scene-scoped services follow the scene's lifetime; application-scoped services can be shared across scenes.
-## How a project is shaped
+You do not need custom renderers, an entity-component framework, or an extension to draw your first object. Begin with one application and one scene, then add structure when the project needs it.
-A typical ExoJS project starts with two pieces: an [`Application`](/ExoJS/en/api/application/) and a [`Scene`](/ExoJS/en/api/scene/).
+## Coming from another engine?
-The application owns the runtime configuration — render backend, canvas size, asset path, frame loop. The scene loads resources, updates state, and draws each frame.
+Treat an ExoJS scene as a lifecycle host, not as a complete saved project. Containers compose transforms and drawing order. Scene navigation manages activation, pause, retention, and teardown. Physics bodies, rendered objects, and serialized data are separate concepts even when a helper connects them.
-
+Those boundaries are useful when embedding a canvas in a web application: the browser page still owns its routing, DOM, and accessibility. ExoJS owns the runtime you put inside it.
-From there, the same shape scales up: add sprites, split logic across multiple scenes, attach input, play audio, render effects, and move code into reusable objects as the project grows.
+## Distribution and maturity
-## Distribution
+ExoJS provides ESM, TypeScript declarations, and prebuilt script-tag bundles. `exo.iife.js` contains Core; `exo.full.iife.js` also includes the official runtime extensions except React and tilemap physics. Both expose `Exo`. The [Setup guide](/ExoJS/en/guide/getting-started/setup/) starts with ESM and a bundler.
-ExoJS ships as ESM with TypeScript declarations. JavaScript projects can use the runtime directly; TypeScript users get typed APIs out of the box. A standalone browser bundle for `
+const deploymentBase = new URL(import.meta.env.BASE_URL, document.baseURI);
+const assetBase = new URL('assets/', deploymentBase).href;
+const app = new Application({ loader: { basePath: assetBase } });
```
-For the debug bundle (`exo.debug.esm.js`), note that it is an **external-core** bundle: it imports `@codexo/exojs` from outside itself. You must map that specifier to the core bundle using an import map:
+Add the scene and canvas options from your application entry point; the fragment above isolates URL configuration. A descriptor such as `image/hero.png` then resolves below that asset base. Do not prefix it again with `assets/` or `public/`.
-```html
-
+Files under `public/` are copied unchanged. Imported assets follow the bundler's asset pipeline. Check exact filename casing and ensure that a missing asset returns a real failure rather than the host's HTML application fallback.
-
-```
+Use a static host that can serve the entire output with correct relative paths and headers. For an embedded HTML game, package the contents of `dist/` at the archive root and configure the embedding surface to match the application's sizing policy.
-Without the import map, `exo.debug.esm.js` cannot resolve `@codexo/exojs` and will fail with a module not found error. The debug bundle is not a standalone engine; it extends the core bundle.
+For GitHub Pages or another CI-based host, use its maintained deployment workflow and publish only after the build succeeds. Keep hosting credentials in the host's secret store, not in application JavaScript. A browser bundle cannot keep a secret from its user.
-Import maps are supported in all modern browsers. If you need to support older environments, use the npm + Vite workflow instead.
+Verify JavaScript MIME types, font and media responses, HTTPS, and any required CORS headers. Both `text/javascript` and other standards-compatible JavaScript MIME types can be valid; an HTML fallback or `text/plain` response for a module is a common failure.
-### Script-tag (IIFE) bundle
+## Using a release bundle without a bundler
-If you don't have any module-aware setup — no bundler, no import maps, just a plain HTML file with `
-
-```
-
-There is no `import` statement anywhere in this path — `Exo` is already in scope by the time the second `
+
```
-Prefer the ES module bundle when your host supports import maps and you want the browser to load only what you use. Prefer the IIFE bundle when you need a true zero-build setup — for example a single static HTML file, a CMS or forum post that only allows pasting a `