npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

playverse-makers

v0.4.1

Published

Babylon.js runtime for maps authored in the Makers editor. One-call facade (loadMakersMap) + character controller + trigger dispatcher.

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

| | 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 \
  earcut

Babylon 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-client

Skipping 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 dev

Done. 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 --havok

npx 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 \
  earcut

Add 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 dev

Click 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):

  1. version supplied → fetch /maps/{id}/v{n}.json from public storage
  2. Row's published_url → fetch
  3. Row's published_version → derive storage URL → fetch
  4. Row's inline data field (only when status is published / 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 mapId is wrong (typo, deleted map)
  • The map's is_public flag is off and status is draft
  • 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-makers to 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 the Access-Control-Allow-Origin header on that server
  • You configured a custom publicKey for 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.00.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.x0.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 own
  • loadModel(scene, { modelId }) — same for Playverse models
  • configurePlayverse(...) — staging / self-host / fork override
  • Typed errors (PlayverseNotFoundError etc.)

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 (with MIGRATION.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 MapDocument JSON.

License

MIT