playverse-makers
v0.4.1
Published
Babylon.js runtime for maps authored in the Makers editor. One-call facade (loadMakersMap) + character controller + trigger dispatcher.
Maintainers
Readme
playverse-makers
Babylon.js runtime for maps authored in the Playverse Makers editor. One call (
loadMakersMap) builds the scene, attaches physics, spawns a first-person controller, and wires triggers — so a game team only writes gameplay logic, not scene assembly code.
import { Engine, Scene } from '@babylonjs/core';
import { loadMakersMap } from 'playverse-makers';
const scene = new Scene(new Engine(canvas, true));
// One line — pulls a published map from Playverse and wires it up
const map = await loadMakersMap(
scene,
{ mapId: '019dc9f1-1892-7e94-b8ed-25afcdf8d992' },
{ player: { canvas } }
);
map.onEvent((name, payload) => {
if (name === 'collect-coin') addScore(10);
});
scene.getEngine().runRenderLoop(() => scene.render());🤖 Using AI to write your game? This package ships an
AGENTS.md— a tight decision tree designed for LLM coding assistants. Two ways to get the AI bootstrap into your project:# Brand-new project — one command, scaffolds everything: pnpm create playverse-game my-game # Existing project — add the bootstrap CLAUDE.md to the root: pnpm add playverse-makers && npx playverse-makers init
Table of contents
- What you get
- Install
- Quickstart — your first map
- Loading sources
- Recipes
- API quick reference
- Trigger actions
- Configuration deep dive (
configurePlayverse) - Troubleshooting
- Migration
- Versioning policy
- Related packages
- License
What you get
| | Without this SDK | With this SDK |
| ------------------------------- | --------------------------------------------------------------- | --------------------------------------------------- |
| Mesh / model placement | Manually parse JSON, instantiate Babylon meshes, set transforms | buildMap does it |
| Models (glb / gltf / obj / stl) | SceneLoader.ImportMeshAsync per file, normalize, parent | loadRemoteModel / loadModel |
| Lights & environment | Read each light spec, create matching Babylon class | One pass in applyEnvironment |
| Physics | Wire Havok plugin, build colliders per node, sync transforms | enablePhysicsForScene + attachPhysicsAggregates |
| FPS controller | Capsule + WASD + jump + mouselook + camera | CharacterController |
| Triggers | Per-frame intersection check, dispatch enter/exit | TriggerManager |
| Trigger actions | Switch on verb, look up nodes, mutate scene | GameplayRuntime |
| Schema migration | Track schema versions, migrate older saves | migrate() runs in the loader |
| Backend (Playverse) | Write your own ConnectBase client + RLS-aware fetch | loadMakersMap(scene, { mapId }) |
Pick the level you want — loadMakersMap is the one-liner; every primitive
underneath is also exported for custom render pipelines, hot-rebuilding,
and headless tests.
Install
pnpm add playverse-makers \
@babylonjs/core @babylonjs/loaders @babylonjs/materials @babylonjs/havok \
earcutBabylon and friends are peer dependencies — game projects bring their own copies so the bundle isn't deduped wrong.
If you'll be using Playverse-direct loading ({ mapId } / { modelId }),
also add the optional peer dep — most projects already have it for their
own ConnectBase data:
pnpm add connectbase-clientSkipping this is fine when you load maps via URL / async-loader / in-memory
MapDocument only — the Playverse-direct code path is lazy-imported and
never touches connectbase-client unless invoked.
Havok wasm
Babylon Havok loads /havok/HavokPhysics.wasm at runtime. Copy it from
node_modules once during install:
// package.json
{
"scripts": {
"postinstall": "node -e \"require('node:fs').mkdirSync('public/havok',{recursive:true}); require('node:fs').copyFileSync('node_modules/@babylonjs/havok/lib/esm/HavokPhysics.wasm','public/havok/HavokPhysics.wasm')\""
}
}SSR (Next.js / TanStack Start / Remix)
Babylon is browser-only — module-level imports crash on the server. Wrap
all Babylon + playverse-makers imports in dynamic imports inside the
component:
useEffect(() => {
(async () => {
const [{ Engine, Scene }, { loadMakersMap }] = await Promise.all([
import('@babylonjs/core'),
import('playverse-makers'),
]);
// ... canvas / engine / loadMakersMap
})();
}, []);A complete SSR-safe canvas component lives in the NJB example.
Quickstart — your first map
Fastest path: create-playverse-game
pnpm create playverse-game my-game
cd my-game
pnpm install
pnpm devDone. Vite + Babylon + Havok postinstall + playverse-makers + a
CLAUDE.md already wired for AI assistants — no manual copy steps.
Existing project? Add the SDK to it
pnpm add playverse-makers \
@babylonjs/core @babylonjs/loaders @babylonjs/materials @babylonjs/havok \
earcut
# (recommended) drop the AI bootstrap CLAUDE.md at the project root
npx playverse-makers init --havoknpx playverse-makers init copies templates/CLAUDE.md to your project
root and (with --havok) adds the postinstall hook below to your
package.json for you.
Manual setup
If you'd rather not run the CLI:
pnpm create vite@latest my-game -- --template vanilla-ts
cd my-game
pnpm add playverse-makers \
@babylonjs/core @babylonjs/loaders @babylonjs/materials @babylonjs/havok \
earcutAdd the postinstall hook from above to copy the Havok wasm, then copy
node_modules/playverse-makers/templates/CLAUDE.md to the project root.
2. Canvas + engine
<!-- index.html -->
<canvas id="game" style="width:100vw;height:100vh;display:block"></canvas>
<script type="module" src="/src/main.ts"></script>// src/main.ts
import { Engine, Scene } from '@babylonjs/core';
import { loadMakersMap } from 'playverse-makers';
const canvas = document.getElementById('game') as HTMLCanvasElement;
const engine = new Engine(canvas, true);
const scene = new Scene(engine);
const map = await loadMakersMap(
scene,
{ mapId: '019dc9f1-1892-7e94-b8ed-25afcdf8d992' }, // a real published Playverse map
{ player: { canvas } }
);
map.onTrigger('*', (ev) => console.info('[trigger]', ev));
map.onEvent((name, payload) => console.info('[event]', name, payload));
engine.runRenderLoop(() => scene.render());
window.addEventListener('resize', () => engine.resize());pnpm devClick the canvas to lock the cursor → WASD + mouselook + space to jump. That's it.
3. Make it your game
Replace the trigger and event handlers with your scoring / inventory /
dialogue / cutscene logic. The map's spawn nodes, trigger volumes, and
named props are all surfaced on the MakersMap handle (see
API quick reference).
Loading sources
loadMakersMap(scene, source, options?) accepts four source shapes —
pick whichever fits your stack:
1. Playverse { mapId } (zero config)
// Latest published version
const map = await loadMakersMap(scene, { mapId: '019e...' });
// Pin a specific version (recommended for shipped builds — immutable)
const ship = await loadMakersMap(scene, { mapId: '019e...', version: 3 });The SDK ships the canonical Playverse public key, table IDs, and storage IDs as defaults. No ConnectBase client setup, no RLS plumbing. Resolution chain (first hit wins):
versionsupplied → fetch/maps/{id}/v{n}.jsonfrom public storage- Row's
published_url→ fetch - Row's
published_version→ derive storage URL → fetch - Row's inline
datafield (only whenstatusispublished/unlisted— drafts are never returned)
2. URL string
const map = await loadMakersMap(scene, 'https://my-cdn/levels/forest.json');For self-hosted maps (S3, public CDN, your own static server). The SDK fetches and JSON-parses for you.
3. In-memory MapDocument
import type { MapDocument } from 'playverse-makers';
const doc: MapDocument = await loadMyDocSomehow();
const map = await loadMakersMap(scene, doc);Already have the document in memory (preloaded, generated, deserialized from IndexedDB)? Pass it directly.
4. Async loader (any backend)
const map = await loadMakersMap(scene, async () => {
const row = await myCb.database.getData(MAPS_TABLE_ID, { where: { id: mapId } });
return JSON.parse(row.data[0].document);
});Plug your own backend — your own ConnectBase app, Supabase, Firebase, S3 + signed URLs, IndexedDB. The loader runs every document through schema migrations, so older saves keep loading without consumer changes.
Recipes
Bring your own camera (3rd person / top-down / on-rails)
Skip the player option, then use the spawn for your camera target:
const map = await loadMakersMap(scene, source); // no player
const start = map.spawn(); // first spawn node
import { ArcRotateCamera, Vector3 } from '@babylonjs/core';
const camera = new ArcRotateCamera(
'cam',
Math.PI / 4,
Math.PI / 3,
10,
new Vector3(...start!.transform.position),
scene
);
camera.attachControl(canvas, true);The trigger manager binds to whatever camera is scene.activeCamera at
load time — set it before calling loadMakersMap.
Multiple maps in one session (level switching)
Always dispose() the old map before loading the new one:
let current: MakersMap | null = null;
async function loadLevel(mapId: string) {
current?.dispose();
// optional: also clear the scene's meshes if you don't want overlap
scene.meshes.slice().forEach((m) => m.dispose());
current = await loadMakersMap(scene, { mapId }, { player: { canvas } });
}dispose() cleans up the player, trigger manager, and event subscriptions.
Built-in primitives (mesh, model, light) live on the Scene — call
scene.dispose() for those.
Per-trigger event handlers
const offDoor = map.onTrigger('door-1', (ev) => {
if (ev.type === 'enter') openDoor();
});
// later — selectively unsubscribe
offDoor();Wildcard '*' fires for every trigger. Returns an unsubscribe function.
Score / inventory via event: actions
In the editor, configure a trigger action like event:collect-coin with
payload { value: 10 }. Then in your game:
map.onEvent((name, payload) => {
switch (name) {
case 'collect-coin':
addScore((payload as { value: number }).value);
break;
case 'show-dialogue':
openDialogue(payload as { text: string });
break;
}
});Editor authors don't need to know your runtime — they emit event:,
your game decides what it means.
Single model viewer (no map)
import { Engine, Scene, ArcRotateCamera, HemisphericLight, Vector3 } from '@babylonjs/core';
import { loadModel } from 'playverse-makers';
const scene = new Scene(new Engine(canvas, true));
new ArcRotateCamera('cam', Math.PI / 4, Math.PI / 3, 5, Vector3.Zero(), scene).attachControl(
canvas,
true
);
new HemisphericLight('light', new Vector3(0, 1, 0), scene);
const hero = await loadModel(scene, { modelId: '019d...' });
scene.getEngine().runRenderLoop(() => scene.render());Use this for character pickers, inventory previews, asset previewers.
Preload thumbnail + metadata without loading the scene
import { fetchPlayverseMap, fetchPlayverseModel } from 'playverse-makers';
const doc = await fetchPlayverseMap(mapId);
console.log('spawns:', doc.nodes.filter((n) => n.kind === 'spawn').length);
const meta = await fetchPlayverseModel(modelId);
console.log('format:', meta.format, 'name:', meta.name);These return parsed/typed metadata without touching Babylon — useful in list views, SSR-rendered cards, headless validators.
Pin a version for shipped builds
// During development — latest published, easy to iterate on
const dev = await loadMakersMap(scene, { mapId });
// In production — frozen at the version you tested against
const prod = await loadMakersMap(scene, { mapId, version: 7 });Versioned URLs are immutable — same path forever. A map author republishing won't silently change your shipped game.
Self-host a MapDocument (no Playverse, no CB)
// Bundle the JSON next to your app, OR put it on a public S3 bucket
const map = await loadMakersMap(scene, '/levels/forest.json');Authoring on Playverse and shipping standalone is fine. Hit Publish → Download JSON in the editor and check it into your repo.
Disable physics for showcase scenes
await loadMakersMap(scene, source, { physics: false, player: false });Skips Havok init and FPS controller. Pure visual scene + meshes you can move with your own logic.
API quick reference
loadMakersMap(scene, source, options?)
function loadMakersMap(
scene: Scene,
source: MapSource,
options?: LoadMakersMapOptions
): Promise<MakersMap>;
type MapSource =
| MapDocument
| LooseMapDocument
| string
| (() => Promise<MapDocument | LooseMapDocument>)
| { mapId: string; version?: number };
interface LoadMakersMapOptions {
/** Default `true`. Initialize Havok and attach physics to nodes with a `physics` spec. */
physics?: boolean;
/** Spawn an FPS controller. `false` to bring your own. */
player?: false | LoadMakersMapPlayerOptions;
/** Sink for trigger `log:` actions. Default `console.log`. */
log?: (msg: string) => void;
}loadModel(scene, source, options?)
function loadModel(
scene: Scene,
source: ModelSource,
options?: LoadRemoteModelOptions
): Promise<LoadedAsset>;
type ModelSource = { url: string; format: ModelFormat } | { modelId: string };MakersMap handle
class MakersMap {
doc: MapDocument
scene: Scene
spawns: SpawnNode[]
triggerNodes: TriggerNode[]
player: CharacterController | null
runtime: GameplayRuntime | null
triggerManager: TriggerManager | null
byName(name: string): TransformNode | null // first match
byNameOrThrow(name: string): TransformNode // throws if missing
byTag(tag: string): TransformNode[] // all matches
spawn(name?: string): SpawnNode | null // default = first
onTrigger(name: string, fn): () => void // unsubscribe via return
onTrigger('*', fn) // wildcard for every trigger
onEvent(fn): () => void // event:/custom: actions
dispose(): void // tears down player + triggers
}Errors
class PlayverseNotFoundError extends Error {
code: 'NOT_FOUND';
}
class PlayverseNotPublishedError extends Error {
code: 'NOT_PUBLISHED';
}
class PlayverseFetchError extends Error {
code: 'FETCH_FAILED';
}
class PlayverseSchemaError extends Error {
code: 'INVALID_SCHEMA';
}Use instanceof to branch UX (404 vs draft vs network):
try {
await loadMakersMap(scene, { mapId });
} catch (e) {
if (e instanceof PlayverseNotPublishedError) showDraftWarning();
else if (e instanceof PlayverseNotFoundError) show404();
else if (e instanceof PlayverseFetchError) showRetry();
else throw e;
}Low-level building blocks
For custom render pipelines, hot-rebuilding, headless tests, multi-scene setups:
| Symbol | Purpose |
| --------------------------------------- | ---------------------------------------- |
| buildMap(scene, doc) | Build everything from a MapDocument |
| buildNode(scene, node) | Build a single node |
| rebuildVisual(scene, builtNode, node) | Swap visuals (editor) |
| applyEnvironment(scene, env) | Background, ambient, gravity, grid |
| serializeMap(scene, meta) | Scene → MapDocument |
| initHavok() | Preload Havok wasm once |
| enablePhysicsForScene(scene, gravity) | Install Havok plugin |
| attachPhysicsAggregates(scene) | Attach bodies for physics-tagged nodes |
| CharacterController | Havok capsule FPS player |
| GameplayRuntime | Built-in trigger action dispatcher |
| TriggerManager | Per-frame intersection checks |
All schema types (MapDocument, MapNode, MaterialSpec, etc.) and
helpers (migrate, validateMap, collectAssetUrls) are re-exported
from playverse-makers-spec.
Trigger actions
Each trigger fires onEnter / onExit actions when the player camera
crosses its volume. Two formats coexist — game projects should prefer
the structured form (actions.onEnter[]) over the legacy string DSL.
| Verb | Structured form | String DSL |
| --------------------- | -------------------------------------------- | ------------------ |
| Log | { type: 'log', message } | log:msg |
| Teleport | { type: 'teleport', target: spawnName } | teleport:Spawn1 |
| Show / Hide | { type: 'show'\|'hide', target: nodeName } | show:Door |
| Color | { type: 'color', target, rgb } | color:Door:1,0,0 |
| Impulse | { type: 'impulse', vec } | impulse:0,8,0 |
| Event (game-defined) | { type: 'event', name, payload? } | — |
| Custom (escape hatch) | { type: 'custom', name, payload? } | — |
event and custom actions never run the built-in dispatcher — they
reach your code through map.onEvent(...).
Configuration deep dive (configurePlayverse)
99% of game projects never call this — the SDK comes pre-configured for the canonical Playverse instance. Call it once at app boot when you do.
import { configurePlayverse } from 'playverse-makers';
configurePlayverse({
publicKey: 'cb_pk_...', // staging / fork / self-hosted
mapsTableId: '...',
userModelsTableId: '...',
assetsStorageId: '...',
fetch: customFetch, // signed requests, polyfill, mock
cb: existingClient, // reuse your app's CB session
});| Option | Default | When to override |
| ------------------- | ---------------------------- | -------------------------------------------------------- |
| publicKey | Canonical Playverse anon key | Staging instance, self-hosted Playverse fork |
| mapsTableId | Production maps table | Custom Playverse fork |
| userModelsTableId | Production user_models table | Custom fork |
| assetsStorageId | Production assets storage | Custom fork |
| fetch | Global fetch | SSR (node-fetch), polyfills, signed requests, test mocks |
| cb | Auto-built singleton | Reuse an existing authenticated CB client + session |
Multi-environment app:
const env = import.meta.env.MODE;
configurePlayverse({
publicKey: env === 'production' ? PROD_KEY : STAGING_KEY,
});Troubleshooting
PlayverseNotPublishedError: Playverse map has not been published
The map exists but has never been published. Drafts are never returned to the SDK (RLS-gated). Open the map in the Playverse editor and click Publish.
PlayverseNotFoundError: Playverse map not found
Either:
- The
mapIdis wrong (typo, deleted map) - The map's
is_publicflag is off andstatusisdraft - The SDK key cannot read it under RLS (paranoid privacy mode)
In dev, double-check the UUID by visiting https://playverse.web.connectbase.world/play/<mapId>.
PlayverseFetchError: Playverse fetch failed: ... (5xx)
The published JSON URL returned a server error. The SDK already retries once with a 200ms backoff — if you still see this, the upstream is genuinely down. Show a "try again later" UI.
PlayverseSchemaError
The fetched document does not match the MapDocument shape. Possible
causes:
- The wrong URL was fetched (HTML instead of JSON — check CORS / auth)
- A future schema version this SDK doesn't know about — upgrade
playverse-makersto the latest minor
CORS errors in browser console
The Playverse default endpoint allows * origin. If you see CORS errors:
- You're loading from a custom URL (
source: 'https://my-cdn/...') — add theAccess-Control-Allow-Originheader on that server - You configured a custom
publicKeyfor a self-hosted instance — make sure that instance's storage / database CORS is open
Babylon "ReferenceError: window is not defined" on the server
Babylon is browser-only. Move all @babylonjs/* and playverse-makers
imports inside a useEffect (React) or onMount (Svelte) and use
dynamic import() — see SSR.
HavokPhysics.wasm 404
The Havok wasm wasn't copied into your static directory. Add the
postinstall hook from Install and re-run pnpm install.
Map loads but the player doesn't move
The pointer lock isn't engaged. Click on the canvas — the
CharacterController listens for pointerlockchange and only
processes mouse-look while locked.
Models / textures fail to load with 401 / 403
The model file at the URL is not public. Check the model's is_public
flag (Playverse modelers can mark uploads public; private models are
owner-read-only).
Migration
0.2.0 → 0.2.1
No code changes required. v0.2.1 fixes a fallback bug where some
production-published rows (status: published but published_url
empty) couldn't be loaded. Drop-in upgrade.
0.1.x → 0.2.x
Additive — every existing call site keeps working.
New in 0.2:
{ mapId, version? }source shape — pull from Playverse without a ConnectBase client of your ownloadModel(scene, { modelId })— same for Playverse modelsconfigurePlayverse(...)— staging / self-host / fork override- Typed errors (
PlayverseNotFoundErroretc.)
If you were using the async-loader pattern to talk to your own CB:
// Before — still works
loadMakersMap(scene, () => loadMyMap(cb, mapId));
// After — also works (when the map lives on Playverse)
loadMakersMap(scene, { mapId });Versioning policy
playverse-makers follows SemVer:
- Patch (
0.2.x) — bug fixes, no API changes - Minor (
0.x.0) — additive API, no breaking changes - Major (
x.0.0) — schema-breaking changes (withMIGRATION.md)
The schema itself (playverse-makers-spec)
versions independently — older saves migrate forward automatically via
migrate() baked into the loader.
Related packages
playverse-makers-spec— pure TypeScript schema + migrations + validators. Babylon-free. Use this for Three.js / Unity / Godot adapters, server validation, and AI tooling.- NJB — reference consumer (TanStack Start + Vite). Demonstrates Playverse direct loading and an own-CB async loader side by side.
- Playverse Makers editor —
authoring tool that produces
MapDocumentJSON.
