Xyz
State ownership primitives, a declarative Tweakpane debug layer, and Three.js scene warmup to kill first-frame hitches.
JOYCO's 3D toolkit. Keyed state ownership, typed definitions, CDN asset routing, ordered teardown, a declarative debug-pane layer over Tweakpane, and Three.js scene warmup that forces every shader compile and texture upload to happen before the player ever sees a hitch.
Mental model
Xyz has three tree-shakeable entrypoints:
- Core (
@joycostudio/xyz) —Derivedandedgefor state that multiple owners write to and one policy resolves;defineAssetSourcefor Chopper-aware CDN paths;Disposerfor teardown that unwinds in reverse;ModuleandModuleHostfor ownership trees, selected-state sync, typed events, ticking, warmup, and inspection snapshots. - Debug (
@joycostudio/xyz/debug) —DebugManagerowns URL/keyboard activation and lazy pane creation; a declarative schema (slider,toggles,select,color,section,button, …) binds to a live Tweakpane folder without hand-wired glue. - Three (
@joycostudio/xyz/three) —Warmupdiscovers a scene's full renderable graph, exposes it long enough to push every texture/shader through the driver once, then restores every mutation exactly as it found it.
Features
| Feature | Description |
|---|---|
Keyed ownership (Derived) | Independent writers claim a slot by key; value resolves all current intents through one explicit policy. |
Transition sync (edge) | Turns a selector into a callback that only fires when the selected value actually changes. |
Asset routing (defineAssetSource) | Keeps source names at call sites while selecting one output from a Chopper-compatible optimization plan. |
Ordered teardown (Disposer) | Registrations unwind in reverse order; disposal is idempotent and safe to register into after teardown. |
| Declarative debug schema | slider, sliders, bool, toggles, select, color, colorString, section, button build a DebugSchema with no Tweakpane import required. |
| URL + keyboard activation | DebugManager lazy-mounts the pane on ?debug or Option+D, and stays in sync with popstate. |
| Survives enable/disable | Registrations remount from fresh definitions every cycle — no stale bindings after a toggle. |
| Scene warmup | Warmup inventories geometries, materials, and textures (including TSL node graphs), pauses the driver, and forces one representative render before restoring visibility, culling, and draw ranges. |
| Late-resource auditing | audit() warns once per resource introduced after warmup — a throttled dev-only diagnostic, never a runtime upload. |
| No R3F dependency | The Three.js utilities are raw Three.js — bring your own renderer/canvas ownership split. |
Install
pnpm add @joycostudio/xyzthree and @tweakpane/core are optional peer dependencies — install them only if you use the /three or /debug subpaths.
defineAssetSource accepts Chopper's resolve function structurally, so Xyz does not require Chopper as a peer. Install @joycostudio/chopper in the consuming app when its config supplies the optimization plan.
Quick start
import { Derived, edge, maxOf } from '@joycostudio/xyz'
const blur = new Derived(0, maxOf)
blur.set('loader', 0.8)
blur.set('inspect', 0.35)
blur.value // 0.8
const syncPhase = edge(
(state: { phase: string }) => state.phase,
(next, previous) => console.log(previous, '→', next)
)Asset routing
import chopper from './chopper.config'
import { defineAssetSource } from '@joycostudio/xyz'
export const asset = defineAssetSource({
url: new URL('https://r2.joyco.studio/jam/'),
planner: chopper.resolve,
optimize: () =>
typeof window === 'undefined' || new URLSearchParams(window.location.search).get('compressed') !== 'false',
segments: {
common: 'common',
escapeFromHell: 'escape-from-hell',
},
})
asset.common('textures/noise.png')
// https://r2.joyco.studio/jam/common/textures/compressed/noise.webpThe planner sees the stable source key without its query or hash. Exactly one planned output is selected; unmatched, failed, or multi-output plans fall back to the source path.
Quick start (modules)
import { Module, ModuleHost } from '@joycostudio/xyz'
class Counter extends Module<{ step: number }> {
private count = 0
advance() {
this.emit('step', ++this.count)
}
}
class Application extends Module {
readonly counter = this.attach(new Counter())
mount() {
this.listen(this.counter, 'step', (count) => console.log(count))
}
}
const host = new ModuleHost({
ticker: { add: () => undefined }, // Supply your frame scheduler when modules tick.
defaultTickPriority: 0,
})
const app = host.mount(new Application())
app.counter.advance()
host.dispose() // Stops listeners and disposes children before their owner.Declare store selections with this.select(store, selector); the host shares one
subscription per store and calls sync() when selected values change. Stores only
need getState() and subscribe(); Zustand works directly without becoming an XYZ
runtime dependency. Tickers supply milliseconds, and module tick(dt, time) receives
seconds. Root modules choose priority; their children tick in ownership order.
host.inspect() returns detached ownership snapshots. host.subscribeActivity()
captures bounded event/state traces on demand outside production. publishModuleTree,
also exported from core, provides a same-origin development iframe bridge for a custom
inspector. See the module reference
and inspection reference.
Use named ESM imports so unused exports can be removed. Core utilities do not
pull in the module runtime, inspector bridge, React, Tweakpane, or Three.js when
bundled on their own. ModuleHost includes its snapshot and activity methods;
activity recording starts only with a subscriber and is disabled in production.
The iframe bridge is removable independently of the runtime.
Quick start (debug)
import { DebugManager, property, slider, bool } from '@joycostudio/xyz/debug'
import { Pane } from 'tweakpane'
const state = { amount: 0.5, enabled: true }
const debug = new DebugManager({
createPane: () => new Pane({ title: 'Scene' }),
})
debug.bind({
title: 'scene',
schema: () => ({
amount: slider(property(state, 'amount'), { min: 0, max: 1 }),
enabled: bool(property(state, 'enabled')),
}),
})Open with ?debug=true in the URL, or press Option+D.
Quick start (three)
import { Warmup } from '@joycostudio/xyz/three'
const warmup = new Warmup({
pause: () => (clock.running = false),
resume: () => (clock.running = true),
initTexture: (texture) => renderer.initTexture(texture),
compile: (scene, camera) => renderer.compileAsync(scene, camera),
render: () => renderer.render(scene, camera),
nextFrame: () => new Promise((resolve) => requestAnimationFrame(() => resolve())),
})
await warmup.start(scene, camera) // one representative frame, then the scene is restored
warmup.audit(scene) // dev-only: warns about resources introduced after warmupArchitecture
| Layer | Responsibility |
|---|---|
Core state (Derived, edge) | State resolution primitives with no DOM or rendering dependency. |
Module ownership (Module, ModuleHost) | Renderer-independent lifecycle, shared store subscriptions, typed events, and one ticker callback per root. |
| Definitions and assets | Literal-preserving configuration and pure source-to-CDN URL planning. |
| Disposer | An owner-local teardown stack shared by the debug and three layers (and your own code). |
Debug schema (schema.ts) | Pure DebugField/DebugDefinition builders — no Tweakpane import, fully testable in isolation. |
Debug binding (bind.ts) | Binds a DebugDefinition into a live FolderApi: tracks live-refresh fields, nested sections, and computed sources for copy. |
| DebugManager | Owns URL/keyboard activation and lazy pane creation; registrations survive disable/enable and remount fresh. |
| Warmup orchestration | Warmup pauses the driver, runs registered Warmable.warmup() hooks, uploads textures, compiles, renders once, then restores everything in reverse — even on failure. |
Core API
State
| Export | Description |
|---|---|
Derived<In, Out> | .set(key, value) / .clear(key) claim or release a slot; .value resolves through combine. |
maxOf | Resolve to the highest active value, never lower than the fallback. |
latestOf | Resolve to the most recently claimed slot. |
edge(select, onChange, equals?) | Returns a sync function: runs select every call, onChange only when the result changes. |
Disposer | .add(teardown), .listen(target, type, fn, options?), .dispose() — reverse-order, idempotent. |
defineAssetSource(definition) | Create a callable CDN resolver with typed builders for every declared segment. |
Debug
| Export | Description |
|---|---|
DebugManager | .register(source) / .bind(definition) to mount; .enable() / .disable() / .toggle() / .sync() control activation. |
bindDebugSchema(host, definition, opts?) | Binds a DebugDefinition into an existing Tweakpane folder; returns apply() / refresh() / values(). |
ref(read, write) / property(obj, key, changed?) | Adapt a getter/setter pair, or an object property, into a ValueRef. |
slider / sliders | One bounded numeric control, or several declared from an object's numeric keys at once. |
bool / toggles | One boolean control, or several declared from an object's boolean keys at once. |
select / enumField | A string/number option control (enumField is an alias). |
color / colorString / hexColor | Bind an RGB-compatible object, or a CSS color string (hexColor is an alias). |
folder / section | Nest a DebugDefinition as a field; section adds a collapsed, copyable group. |
button | Declare a sync or async debug action, with optional feedback text. |
schemaValues(schema) | Read every control's current value (recursing into folders, skipping buttons). |
Three
| Export | Description |
|---|---|
Warmup | .add(entity) registers a Warmable; .start(scene, camera) runs once; .audit(scene); .dispose(). |
Warmable | Interface for entities whose render state isn't already in the scene — implement a synchronous or asynchronous warmup() hook. |
WarmupDriver | Renderer-owned hooks the caller supplies: pause, resume, initTexture, compile, render, nextFrame. |
Package exports
| Import path | Contents |
|---|---|
@joycostudio/xyz | Disposer, Derived, maxOf, latestOf, edge, defineAssetSource, and their types. |
@joycostudio/xyz | Module, ModuleHost, store/ticker contracts, ownership snapshots, and activity types. |
@joycostudio/xyz | publishModuleTree, inspector protocol constants, ModuleTree, and TreeMessage. |
@joycostudio/xyz/debug | DebugManager, bindDebugSchema, ref, property, slider, sliders, bool, toggles, select, color, colorString, hexColor, enumField, folder, section, button, schemaValues, and their types. |
@joycostudio/xyz/three | Warmup, discoverScene, and their types. |
Docs
The Fumadocs Core-backed site in apps/docs keeps its authored pages in apps/docs/content/docs as Markdown/MDX. The site shell uses the JOYCO UI kit, while the content embeds React examples from apps/docs/demos, including raw Three.js examples with no React Three Fiber dependency.
pnpm docsOpen the printed local URL and choose a concept from the catalog. Each page starts with a live demo and its actual source files in tabs directly underneath, followed by the API reference. Source is read from the same files that run the demo, including supporting files for multi-file examples. Register examples in apps/docs/lib/demos.ts and embed them with <DemoPreview name="…" />. The debug runtime page demonstrates DebugManager, URL semantics, and Option+D handling, while the declarative schemas page collects the live Tweakpane helpers. Use pnpm docs:build to verify the production integration.
Authored flow and seq code fences render as server-rendered SVG diagrams using
Trazo. Add title="Description" after the fence
language for an accessible title. Trazo validates syntax during the docs build;
Markdown endpoints retain the original fences. Readers can expand and copy the
source, and wide graphs scroll without shrinking their labels. Diagram colors
follow the site theme through CSS, without a client-side diagram renderer.
Development
pnpm install
pnpm test # Vitest suite (run); pnpm test:watch for watch mode
pnpm typecheck # tsc --noEmit, plus the docs app
pnpm build # tsup bundles for core, modules, debug, and three
pnpm lint # eslint --fixReleases go through Changesets:
pnpm changeset # describe the change and bump type in a feature PRAfter the feature PR merges, the Release workflow creates or updates Version Packages; merging that generated PR triggers publication. Beta releases use the same flow with Changesets prerelease mode. Follow the release guide; versioning and publishing commands are reserved for GitHub Actions.