Server SDK Reference
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
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):
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:
const roomStates = new WeakMap<PluginServerContext, MyState>();ctx.world
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
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
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
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
ctx.messages.send(sessionId, 'questOffered', { questId });
ctx.messages.broadcast('chestOpened', { chestId, byName });Received on the client via usePluginMessage(type, handler).
ctx.players
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
await ctx.rewards.grantMoney(sessionId, 2500, 'Treasure found'); // cents, ≤ 10 000 € per call
await ctx.rewards.grantXp(sessionId, 150, 'Quest complete'); // ≤ 100 000 per callPersists 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
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
ctx.log('6 chests placed'); // → [plugin:my-treasure] 6 chests placedMessage validation checklist
Every onMessage handler should establish, in order:
- the referenced thing exists (
state.chests.find(...)) - it's in a valid state (not already opened/claimed)
- the player exists and is alive (
ctx.players.get(...),!player.dead) - the player is physically able (distance check vs
player.position) - 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.