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:
local Creator project
→ vibelands check / npm test
→ signed hosted Sandbox build
→ private multiplayer world clone
→ edit, preview and repeatYour 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 Decorative | TIER B Gameplay | |
|---|---|---|
| Entries | client only | client + server |
| State | none replicated - deterministic from world seed | replicated entities + KV + persistent storage |
| Multiplayer | every client computes the same visuals | server-authoritative, host-validated |
| Examples | birds, fireflies, flora, ambient audio | treasure 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 smallpropertiesJsonblob 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:
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 eventsModule 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/physicsEverything 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
| Failure | What the host does |
|---|---|
| WorldLayer throws during render | That plugin's error boundary unmounts only it; the world keeps rendering |
| Frame callback throws repeatedly | usePluginFrame stops calling it after 50 errors |
| Server hook exceeds the tick budget 3 windows in a row | Circuit breaker disables the plugin for that room, clears its timers, removes its entities |
| 5+ errors in one 5 s window | Same 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:
const rng = useDeterministicRng('placement'); // mulberry32(worldSeed ^ pluginId ^ salt)
// same world + same plugin → same sequence on every client, every sessionUse it for placement and counts. Plain Math.random() is fine only for non-shared flair (audio timing jitter).