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

@rydr/core-world-editor

v0.5.0

Published

The shared world-editor core for the RYDR platform: a three.js map editor (viewport, transform gizmo, snapshot undo, GLB/DRACO IO) plus a host shell and capability contract that games build their in-shell editors on.

Downloads

1,253

Readme

@rydr/core-world-editor — the shared world-editor core

This is the canonical, single source of truth for how the world editor works and how a game builds its own editor on top of it. Other docs point here. The neighbouring CLAUDE.md is the internal feature map (for editing the core itself); the decision doc on why the boundary sits where it does lives in the platform repo (docs/world-editing-architecture.md). When in doubt about how to consume the package, read this file.


1. What it is — the two-layer model

The world editor splits cleanly into two concerns, and the package owns exactly one of them:

| Layer | Owner | What it is | |---|---|---| | World geometry / environment | the core (this package) | A CoreWorld = a base GLB + a MapDelta of placed catalog props (crates, walls, buildings) + lighting. Edited with the core's own map editor (MapEditorUI). Stored in the platform worlds library; authored once, reused by every game. | | Gameplay binding | each game (a capability) | How your game sits on a world — TD's grid placement, FPS's spawn/zone markers, racing's route graph. Game-specific. Stored in your game-data, keyed by coreWorldId. |

The platform owns the world; the game owns its relationship to the world. A capability never edits world geometry; the core never knows what a "spawn" or a "route" is. That seam is the whole design.

Data invariant — the world editor loads ONLY the CoreWorld + the GameWorld. Whatever your editor lets you edit must live on the GameWorld document — embed it (TD's placement/gameDelta, FPS's spatial markers). Everything else your game authors — combat scripts, waves, enemy/behaviour catalogs, dialogue — belongs in its own editor with its own content, referenced from the GameWorld by id; the world editor must never load it. This keeps the world editor purely spatial and its inputs to exactly two documents, and tells you which editor a new piece of content belongs in. Externalize part of a GameWorld to a side doc only when it's too big to embed (e.g. Racing's track geometry → an R2 blob behind a URL doc) — a size workaround, not a design default.

So a game's editor is: the core map editor (to view/inspect the world) + a mode toggle into your capability (to author your gameplay layer on it). You do not rebuild the map editor — you mount it.


2. How it's consumed

Same fan-out as @rydr/game-sdk: dev uses a Vite alias to this source (so in-progress core changes are picked up with no rebuild); prod uses the published package. A clone without the rydr-core-world-editor sibling falls back / drops the editor build entry.

// vite.config.ts
const localWorldEditor = resolve(__dirname, "../rydr-core-world-editor/src/index.ts");
const hasCore = existsSync(localWorldEditor);
const worldEditorAlias = hasCore ? { "@rydr/core-world-editor": localWorldEditor } : {};
export default defineConfig({
  resolve: {
    alias: { ...worldEditorAlias },
    dedupe: ["three"], // REQUIRED: the core imports three; without dedupe you get two THREE instances
  },                   //           → broken instanceof / materials / raycasts. Align three versions.
  build: { rollupOptions: { input: {
    // Drop the editor entry when the sibling is absent (CI/prod) so the bundle doesn't fail to resolve.
    ...(hasCore ? { "world-editor": resolve(__dirname, "world-editor.html") } : {}),
  } } },
});
// tsconfig.json — keep in sync with the Vite alias
{ "compilerOptions": { "paths": { "@rydr/core-world-editor": ["../rydr-core-world-editor/src/index.ts"] } } }

If your host code reads import.meta.env, declare vite/client types in your own project (src/vite-env.d.ts with /// <reference types="vite/client" />). The package itself does not depend on Vite-specific globals.


3. The public surface (index.ts)

import {
  // assemble
  createEditorContext,        // (canvas, persistence) → EditorContext (viewport, state, controller, undo, loader)
  registerCapability,         // (ctx, capability, data) → CapabilityRuntime { tools, panels, serialize, dispose }
  MapEditorUI,                // the core map editor (toolbar / properties / outliner) — pass to the shell
  // the shell: ONE core-owned frame + your GameWorldAdapter (the canonical path)
  createEditorShell,          // (ctx, ui, opts) → EditorShell — builds the whole frame, wires everything
  injectEditorTheme,          // (called for you by the shell; export for hand-mounting)
  Viewport, MapSceneController, MapEditorState, UndoManager, GlbLoader, // engine pieces (rarely needed directly)
  // world IO
  loadWorld, setupEmptyWorld, buildWorld, setBaseFromUrl, ensureMapGroup,
  // types you implement / consume
  type EditorContext, type EditorCapability, type EditorApi,
  type EditorShell, type EditorShellOptions, type GameWorldAdapter, type EditorPanelSpec,
  type Tool, type ToolContext, type RegisteredTool, type EditorPanel,
  type CapabilityData, type CapabilityHandle, type SnapshotProvider,
  type WorldPersistenceApi, type CoreWorld, type MapDelta, type CatalogItem,
} from "@rydr/core-world-editor";

The package ships one canonical look and one frame. createEditorShell builds the entire editor chrome (header, viewport toolbar, status bar, sidebar, mode switch) inside a single empty root element and injects the shared theme ({@link injectEditorTheme} / EDITOR_THEME_CSS). A game does not copy CSS or assemble a page — it supplies a GameWorldAdapter + a capability and gets the standard look for free. A chrome/style change happens once, in the core, and lands in every editor. (MapEditorUI is still headless DOM with class names like .map-toolbar/.tool-btn; the theme styles them — you only touch those classes if you mount MapEditorUI by hand without the shell, which games should not do.)


4. How a game builds its editor

The mental model: every game editor edits a GameWorld. A GameWorld (the core interface) is what your editor manages; each game's concrete type is a <GameName>World that is a GameWorld — TD's TowerDefenseWorld, FPS's FpsWorld. They share one shape: a name, a draft flag, a reference to one CoreWorld (coreWorldId — the environment it loads), plus game-specific contents (grid placement, zones, …). The top-right selector always lists GameWorlds; the shell renders the same frame for all of them:

GameWorlds  (your worlds — the selector is always GameWorlds)
Settings    (name + Core World + Draft)              ← identical everywhere
Map ⇄ Contents                                       ← mode switch
  Map → core Properties + Outliner
  Contents → your capability's panel

So you supply exactly three things and the shell does the rest:

  1. WorldPersistenceApi — read the shared world library (read-only). (§4.1)
  2. EditorCapability — author your contents (markers/tools/panel). (§4.2)
  3. GameWorldAdapter — your document's noun + persistence + field accessors. (§4.3)

Then one createEditorShell(...) call wires it together (§4.5). Mirror an existing game — tower-defense (capability = a gizmo-only grid footprint) or FPS / RYDR Gunner (capability = pointer tools + an inspector panel; the richer example).

4.1 Persistence — implement WorldPersistenceApi (read-only for games)

Games read the shared world library through the SDK session and never write it (no Bearer; world geometry is authored in the platform's standalone editor). Your per-game binding is saved by the host (step 4.4) via session.saveContent, not here.

export class MyWorldPersistence implements WorldPersistenceApi {
  constructor(private session: PlatformSession) {}
  async listWorlds()      { return (await this.session.listWorlds()).map(w => w as unknown as CoreWorld); }
  async loadWorld(id)     { const d = await this.session.getWorld(id); return d ? (d as unknown as CoreWorld) : null; }
  async saveWorld()       { throw new Error("games do not write the shared world library"); }
  async deleteWorld()     { throw new Error("games do not delete shared worlds"); }
  async listCatalog()     { return []; }
  async saveCatalogItem() { throw new Error("games do not write the shared catalog"); }
  async uploadAsset()     { throw new Error("games do not upload shared assets"); }
}

4.2 Capability — implement EditorCapability

This is your gameplay layer. register(api) receives an EditorApi and returns a CapabilityHandle. Use:

  • api.addLayer() → a THREE.Group outside the editable map group, so your markers render but are excluded from the world MapDelta. Draw your gameplay markers here.
  • api.attachGizmo(object, { mode, onChange }) / setGizmoMode / detachGizmo → reuse the core's single transform gizmo to move/rotate one of your markers (no second gizmo). onChange fires on drag-end — read the object's transform back into your binding then.
  • api.registerTool(tool) → a pointer Tool (onMouseDown required; onMouseMove/Up/DoubleClick/ KeyDown/Activate/Deactivate/getToolHint optional). The shell auto-renders the tool palette and forwards events to the active tool in Contents mode (§6) — you don't wire any of that.
  • api.registerPanel({ mount, refresh }) → your panel DOM; the shell mounts it in the Contents region.
  • api.registerSnapshotProvider({ createSnapshot, restoreSnapshot }) → fold your domain state into the core's single undo stack, so Ctrl-Z spans map edits and your edits. Record before mutating with api.undo.recordChange().
  • Return serialize() → your binding blob (the host persists it).

4.3 Document — implement GameWorldAdapter<TGameWorld>

This is the new pluggable that unifies "Worlds"/"Levels". It tells the shell your noun, how to list/load/save/delete your documents, how to read/write the three universal fields (name, core-world id, draft), and how to hydrate/flush your contents:

export class MyWorldAdapter implements GameWorldAdapter<MyWorld> {
  readonly noun = { singular: "World", plural: "Worlds" } as const;
  constructor(private session: PlatformSession, private cap: MyCapability) {}
  list()   { /* session.listContent("worlds") → MyWorld[] */ }
  load(id) { /* session.getContent("worlds", id) */ }
  save(d)  { /* session.saveContent("worlds", d.id, d) */ }
  delete(id) { /* session.deleteContent("worlds", id) */ }
  blank()  { /* a fresh empty doc for "+ New" */ }
  getId(d) { return d.id; }
  getName(d) { return d.name; }                         setName(d, v) { return { ...d, name: v }; }
  getCoreWorldId(d) { return d.coreWorldId; }   setCoreWorldId(d, v) { /* + reset placement */ }
  getDraft(d) { return d.draft ?? false; }              setDraft(d, v) { return { ...d, draft: v }; }
  bindContents(d) { /* AFTER the world loads: hydrate your capability from d */ }
  readContents(d) { /* fold your capability's current contents back into d, return it */ }
}

bindContents runs after the core world is in the scene, so you can position contents against the loaded geometry. readContents runs right before save. See tower-defense-world-adapter.ts (immutable doc + grid contents) and fps-world-adapter.ts (mutable doc + the capability holds the live level).

4.4 Host page — world-editor.html (canonical route — MUST)

Every GameWorld editor ships as world-editor.html → route /world-editor → shell deep link /game/<your-game>/world-editor. This is a fixed convention, not a per-game choice: the /game/<id>/ segment already scopes it, so there's no game-/track-/etc. prefix — TD, FPS, and racing all use /world-editor. (Sibling convention: a scenario/timeline editor is /run-editor. A non-world editor — e.g. a song/chart editor with no CoreWorld — is not a GameWorld editor and does not use this route.) Wire the route in your host: a Vite spa-fallback (/world-editor/world-editor.html), the build input entry, and a Vercel rewrite. The editor's source also lives in a canonical src/world-editor/ dir — same name in every game (TD, FPS, racing) regardless of domain.

The platform's own standalone editor (which authors a CoreWorld directly, not a GameWorld) is the exception: it's core-world-editor.html/core-world-editor, matching the @rydr/core-world-editor package. "world-editor" = a game's GameWorld editor; "core-world-editor" = the platform's CoreWorld editor.

A guest page on your game origin, opened in the shell at /game/<your-game>/world-editor. With the shell it collapses to one root + a canvas — the shell builds everything else:

<body>
  <div id="root"></div>
  <canvas id="viewport"></canvas>
  <script type="module" src="/src/world-editor/world-editor-host.ts"></script>
</body>

No <style>, no header/sidebar/toolbar/status-bar markup — those drift, so the core owns them.

4.5 Host script — wire it together (~20 lines)

const { session, isAdmin } = await initPlatform("my-game");             // SDK session (see SDK README)
// Editors are full-screen authoring tools — hide the shell's trainer power bar AND its hamburger
// menu (Exit/hardware/settings/profile). Do this in EVERY world-editor host.
session.setPowerBar(false);
session.setMenu(false);
const ctx = createEditorContext(canvas, new MyWorldPersistence(session)); // the core
const ui = new MapEditorUI(ctx.state, ctx.controller);                   // pass to the shell; do NOT initialize it yourself
const cap = new MyCapability();
const runtime = registerCapability(ctx, cap, { loadBinding: async () => null, saveBinding: async () => {} });
const adapter = new MyWorldAdapter(session, cap);

const shell = createEditorShell(ctx, ui, {
  root: document.getElementById("root")!,
  canvas,
  documentAdapter: adapter,
  capability: runtime,        // its panel fills the Contents region; its tools auto-wire
  contentsLabel: "Gameplay",  // the mode-switch label for your capability
  // gizmo-only capabilities (like TD's grid) activate/deactivate on the mode switch:
  onContentsEnter: () => cap.activate?.(),
  onContentsExit: () => cap.deactivate?.(),
});
ctx.viewport.start();

const docs = await adapter.list();
await shell.openDocument(docs[0] ?? adapter.blank());   // opens it, loads its world, hydrates contents

That's the whole host. The shell owns the Documents list, Settings (name + Core World dropdown + draft), Save/Delete (it calls your adapter — override with onSave only for custom flows), the Map ⇄ Contents toggle, keyboard, fly-camera, status bar, and capability-tool wiring. You only fill slots — and shell.canvasOverlay is there for floating panels over the viewport.


5. The seam — core vs game (do / don't)

| The core gives you | Your game owns | |---|---| | The whole frame + theme: documents list, settings, save/delete, mode switch, toolbar, status bar, keyboard (createEditorShell) | An GameWorldAdapter (noun + persistence + name/world/draft + hydrate/flush) | | Renderer, 2D/3D cameras, orbit, grid, lighting (Viewport) | Your gameplay markers/renderers (on addLayer() groups) | | Map editing: select/move/place/delete world props → MapDelta (MapEditorUI, MapSceneController) | Your tools, panels, and what a drag means | | One transform gizmo, raycast, hover/selection | Your binding schema + its serialize/snapshot | | Snapshot undo stack (UndoManager) you fold into | Persisting your binding (session.saveContent) | | World load/IO (loadWorld, GlbLoader, DRACO) | Reading the world at runtime (applyWorld, SDK) |

Never edit world geometry from a capability, never put gameplay semantics in the core, never hold a Bearer in a game (auth is isAdmin + the session — see the SDK README "Build an in-game editor").


6. Capability tools are wired by the shell (no host work)

registerCapability collects registerTool/registerPanel/registerSnapshotProvider. The shell then does the rest: when you pass capability: runtime, it builds the ToolContext (using viewport.raycastWorld(ndcX, ndcY, ctx.controller.getMapGroup()) — raycast the loaded world, fall back to Y=0), renders the tool palette + hint, tracks the active tool, and forwards mousedown/move/up/dblclick/keydown to it in Contents mode. A gizmo-only capability (TD's grid) just omits tools and fills the Contents panel with its gizmo buttons.

Historical note: this used to be the host's job (the FPS host hand-rolled ndcToWorld + the event forwarding). Those are now promoted into the core — Viewport.raycastWorld() and the shell's internal tool mounting — so no game re-implements them.


7. Worked examples

| Game | Capability shape | Files | |---|---|---| | Tower defense | gizmo-only grid footprint + GridPlacement; shell + TowerDefenseWorldAdapter (noun "World") | rydr-game-tower-defense/src/world-editor/{world-editor-host,grid-capability,tower-defense-world-adapter}.ts | | FPS (RYDR Gunner) | pointer tools (zone post / spawn / approach / point / erase) + inspector + snapshot undo; shell + FpsWorldAdapter (noun "World") | rydr-game-fps/src/world-editor/{world-editor-host,fps-level-capability,fps-world-adapter}.ts |

Both: in-shell guest, isAdmin-gated, read the world library read-only, save their binding via session.saveContent. The platform's own standalone editor (main.ts) is the third consumer (it does write worlds — it's first-party, not a game).

Migrating a bespoke editor

A game with its own hand-rolled editor (e.g. racing's track-editor.html, guitar-hero's editor.html) moves onto the standard look the same way: model what it edits as a Document (it has a name, a draft flag, references one core world, and carries game-specific contents), implement an GameWorldAdapter for it, move its authoring into a capability, and replace the bespoke page + wiring with createEditorShell. Delete the local CSS — the theme replaces it. Do this per game when you touch that editor next; there's no rush to migrate a working bespoke editor pre-emptively.

Deferred follow-up (tracked, not yet built): a per-game-world additive MapDelta layered on top of the core world's, so a game world can place extra props on a shared core world without writing the shared library. Composition order: platform base → platform delta → game-world delta (locked backdrop + editable layer in the editor). The adapter would gain get/setMapDelta.