Skip to content

Server SDK Reference

ts
import { definePluginServer } from '@vibelands/plugin-sdk';

Server entries run inside the world room under host containment: every hook is try/caught, time-budgeted and quota'd. You never see the room, the schema, or other plugins.

Hooks

ts
export const server = definePluginServer({
  tickIntervalMs: 1000,            // default 100, clamped to [16, 5000]
  async onWorldStart(ctx) {},      // room created - load storage, spawn entities
  onPlayerJoin(ctx, player) {},    // snapshot view, never a live reference
  onPlayerLeave(ctx, sessionId) {},
  onTick(ctx, dtSeconds) {},       // accumulated to your tickIntervalMs
  onMessage: {
    myType(ctx, client, data) {},  // client → server; data is HOSTILE - validate
  },
  onEditorObjectPlaced(ctx, event) {},   // a player placed one of this plugin's editorItems
  onEditorObjectRemoved(ctx, event) {},  // ...or deleted one; event = { objectId, itemId, position, rotation, scale, properties, sessionId }
  async onDispose(ctx) {},         // room closing - final saves
});

Additional context APIs for plugins that own world-object presets (manifest editorItems):

ts
ctx.players.canEditWorld(sessionId)        // world owner / admin check
// requires 'world.objects.read' / 'world.objects.write':
ctx.objects.list(preset?)                  // snapshot views of owned-preset objects ({ id, preset, position, properties, farm })
ctx.objects.get(id)
ctx.objects.setFarm(id, patch, { persist? })        // the replicated farm channel (state/cropId/water01/…)
ctx.objects.setProperties(id, props, { persist? })  // merged into the object's properties JSON
// requires 'players.inventory' (mutations push inventoryState to the client):
await ctx.inventory.count(sessionId, itemId)
await ctx.inventory.grant(sessionId, itemId, qty)
await ctx.inventory.remove(sessionId, itemId, qty)

For common player-object interactions, use findNearbyWorldObject(ctx, sessionId, { objectId, itemId | preset, range, yRange, requireMode }). It returns { player, object, distanceSq } | null after validating the message object id, player state, plugin-owned preset and range.

Per-room state

The module is loaded once per process; a context is created per room. Key room state by context, never module-level mutable variables:

ts
const roomStates = new WeakMap<PluginServerContext, MyState>();

ctx.world

ts
ctx.world.worldName / terrainSeed / terrainSize / seaLevel / biome
ctx.world.getGroundHeight(x, z)     // authoritative terrain height
ctx.world.getTimeOfDay()            // 0..1
ctx.world.getWeather()              // { type, intensity }

ctx.entities - replicated objects world.entities

ts
const id = ctx.entities.spawn({
  id: 'chest-0',                    // optional stable suffix → 'my-plugin:chest-0'
  kind: 'chest',                    // your renderer key on the client
  position: { x, y, z },
  rotation?: { x, y, z, w },
  scale?: 1 | { x, y, z },
  velocity?: { x, y, z },
  angularVelocity?: { x, y, z },
  properties?: { tier: 2 },         // JSON ≤ 2 KB - cold data only
  ownerSessionId?: 'abc',
  // Requires physics.colliders too. The host simulates this on the server.
  physics?: {
    type: 'dynamic-box',
    halfExtents: { x: 0.5, y: 0.5, z: 0.5 },
    density: 1,
    friction: 0.6,
    restitution: 0.05,
    linearDamping: 0.08,
    angularDamping: 0.18,
    canSleep: true,
    ccd: false,
  },
});
ctx.entities.update(id, patch);     // transforms batch at 10 Hz; final state always lands
ctx.entities.remove(id);
ctx.entities.has(id) / .list() / .count();

Quota: 384 entities per plugin per world. Transforms are binary delta-encoded; properties is JSON (keep small, change rarely). Dynamic boxes simulate at the room rate, publish at 10 Hz, and suppress repeated sleeping snapshots.

ctx.state - replicated KV blob

ts
ctx.state.set({ phase: 'hunt', opened: 3 });   // ≤ 8 KB, ≤ 2 writes/s (burst 4)
ctx.state.get();

Broadcast to every client; read with usePluginState(). For scoreboards and phase flags - never per-frame data. Over-rate writes defer with last-write-wins.

ctx.storage - persistence storage

ts
await ctx.storage.get<T>(key);       // null when missing
await ctx.storage.set(key, value);   // JSON ≤ 32 KB/value, ≤ 2 MB/plugin/world
await ctx.storage.delete(key);

Namespaced per plugin per world, survives restarts. Awaited I/O does not count against your tick budget.

ctx.messages

ts
ctx.messages.send(sessionId, 'questOffered', { questId });
ctx.messages.broadcast('chestOpened', { chestId, byName });

Received on the client via usePluginMessage(type, handler).

ctx.players

ts
ctx.players.get(sessionId)   // { sessionId, userId, name, level, mode, dead, position } | null
ctx.players.list()

Snapshot views - re-read them per message/tick rather than caching across calls.

ctx.rewards players.modify

ts
await ctx.rewards.grantMoney(sessionId, 2500, 'Treasure found');   // cents, ≤ 10 000 € per call
await ctx.rewards.grantXp(sessionId, 150, 'Quest complete');       // ≤ 100 000 per call

Persists to the player and shows the in-game reward toast. There is no raw player mutation - these wrappers are the only write path.

ctx.schedule

ts
const cancel = ctx.schedule.interval(5000, () => { ... });   // min 50 ms
ctx.schedule.timeout(120_000, () => { ... });

Host-owned timers: automatically cleared when your plugin is disabled or the room closes - no leaks, ever.

ctx.log

ts
ctx.log('6 chests placed');   // → [plugin:my-treasure] 6 chests placed

Message validation checklist

Every onMessage handler should establish, in order:

  1. the referenced thing exists (state.chests.find(...))
  2. it's in a valid state (not already opened/claimed)
  3. the player exists and is alive (ctx.players.get(...), !player.dead)
  4. the player is physically able (distance check vs player.position)
  5. only then mutate, persist, reward, broadcast

The host already rate-limits (20 msg/s per client, burst 40, ≤ 8 KB) and drops unknown types - but semantics are yours to defend.

VibeLands Creator - Plugin SDK apiVersion 2