Skip to content

How It Works

What actually happens between definePluginClient(...) and a chest appearing on twenty players' screens.

The authoring path

The public packages own the source workflow:

text
local Creator project
  → vibelands check / npm test
  → signed hosted Sandbox build
  → private multiplayer world clone
  → edit, preview and repeat

Your local files remain the source of truth. The hosted Sandbox supplies the real host runtime, isolated Tier B worker and multiplayer visual feedback. The in-game editor places or configures content contributed by the loaded package; it is not a second copy of the plugin source.

The big picture

┌────────────────────────── VibeLands Server ──────────────────────────┐
│                                                                      │
│  PluginHost            registers first-party and hosted artifacts     │
│     │                  (workspace packages + signed hosted releases) │
│     ▼                                                                │
│  PluginRoomRuntime     one per world room:                           │
│     │                  • PluginServerContext per enabled plugin      │
│     │                  • lifecycle fan-out, tick accumulators        │
│     │                  • quotas, budgets, circuit breaker            │
│     ▼                                                                │
│  WorldState            pluginEntities / pluginState (Colyseus)       │
│                        + 'pluginMsg' envelope both directions        │
└───────────────────────────────┬──────────────────────────────────────┘
                                │ delta-encoded state + messages
┌───────────────────────────────▼──────────────────────────────────────┐
│  Client                                                              │
│  pluginLoader          workspace import OR remote bundle fetch       │
│     │                  (sha384 integrity check → blob import)        │
│     ▼                                                                │
│  PluginWorldLayers     mounts your WorldLayer in the R3F scene       │
│  PluginHudSurfaces     mounts your HudPanel in the DOM overlay       │
│     │                  each wrapped in an error boundary             │
│     ▼                                                                │
│  SDK hooks             host bridge → terrain, atmosphere, signals,   │
│                        per-plugin entities/state/messages, assets    │
└──────────────────────────────────────────────────────────────────────┘

Capability tiers

TIER A DecorativeTIER B Gameplay
Entriesclient onlyclient + server
Statenone replicated - deterministic from world seedreplicated entities + KV + persistent storage
Multiplayerevery client computes the same visualsserver-authoritative, host-validated
Examplesbirds, fireflies, flora, ambient audiotreasure hunts, vendors, scoreboards, events

Start with tier A. The moment you need the same truth on every screen - a chest that disappears for everyone when one player opens it - you need tier B.

Why a generic plugin channel (and not custom schemas)

Colyseus assigns schema type ids by registration order; one mismatch between client and server corrupts decoding of the entire world state. So plugins never define schemas. Instead the host owns two generic containers:

  • PluginEntityState - id, kind, position/rotation/scale/velocity (binary delta-encoded - the hot path), plus a small propertiesJson blob for cold data.
  • PluginKVState - one small replicated JSON blob per plugin (scoreboards, phase flags).

Messages ride a single envelope - { p: pluginId, t: type, d: data } - rate-limited per plugin per client, routed to your onMessage handlers.

Your client code sees none of this plumbing:

jsx
const chests = usePluginEntityList();          // live array of YOUR entities
const score = usePluginState();                // your replicated KV blob
const send = useSendPluginMessage();           // send('openChest', { chestId })
usePluginMessage('chestOpened', (data) => {}); // server → client events

Module sharing - the one hard constraint

Plugin R3F code must share the host's react, three, and @react-three/fiber instances: two copies of three break instanceof checks, two Reacts break hooks, and a second fiber cannot see the host canvas. Physics is accessed only through the shared curated plugin SDK module.

The hosted sandbox builder solves this at build time: imports of the shared modules are rewritten to read from globalThis.__VIBELANDS_SHARED__, which the host registers at boot. Your bundle is fully self-contained ESM with no bare imports at all - it works from a blob URL with integrity verification, no import maps.

The apiVersion 2 shared contract (from @vibelands/plugin-sdk/contract.json, the single source for the CLI, SDK and host):

react   react/jsx-runtime   react-dom   three
@react-three/fiber   @preact/signals-react
@vibelands/plugin-sdk/client   @vibelands/plugin-sdk/physics

Everything else you import (e.g. pieces of drei) gets bundled into your plugin - safe, since it consumes the shared singletons.

Never import host internals

@vibelands/client, @vibelands/server and @vibelands/shared are off-limits - vibelands check fails the build if you try. The SDK boundary is what lets the host refactor without breaking your plugin.

Containment - what happens when plugins misbehave

FailureWhat the host does
WorldLayer throws during renderThat plugin's error boundary unmounts only it; the world keeps rendering
Frame callback throws repeatedlyusePluginFrame stops calling it after 50 errors
Server hook exceeds the tick budget 3 windows in a rowCircuit breaker disables the plugin for that room, clears its timers, removes its entities
5+ errors in one 5 s windowSame disable path, loud DISABLED log
Quota exceeded (entities, KV, storage, messages)The call is rejected/deferred and logged - never fatal

Live health is visible per world via GET /admin/plugins (health field) and the in-game debug panel. Full numbers in Permissions & Quotas.

Determinism - the tier A superpower

The host never sends decorative state over the network. Instead, every client derives identical content from the world seed:

jsx
const rng = useDeterministicRng('placement');   // mulberry32(worldSeed ^ pluginId ^ salt)
// same world + same plugin → same sequence on every client, every session

Use it for placement and counts. Plain Math.random() is fine only for non-shared flair (audio timing jitter).

VibeLands Creator - Plugin SDK apiVersion 2