Server SDK Reference
import { defineModServer } from '@vibelands/mod-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 mods.
Start with the API map to choose a service, or use exact signatures for parameters and return types. Read state and lifecycle before adding persistence.
Common call results
| Result | How to handle it |
|---|---|
string | null | Save the returned ID; stop if it is null. |
boolean | Check for rejection; do not assume a mutation succeeded. |
Promise<T> | Await it and inspect the resolved result. |
Snapshot or null | Check existence each time; do not cache player state across actions. |
Hosted workers queue legacy synchronous mutations. Their local return values are not a durable host acknowledgement. Async APIs have their own result contracts; the experimental operation API below explicitly confirms selected host actions.
Hooks
export const server = defineModServer({
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) {},
onZoneEnter(ctx, event) {},
onZoneExit(ctx, event) {},
onInteraction(ctx, event) {}, // only after host validation
onActivityEvent(ctx, event) {}, // authoritative graph/lifecycle fact
onPhysicsJointEvent(ctx, event) {}, // created/removed host-owned joint fact
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 mod's editorItems
onEditorObjectRemoved(ctx, event) {}, // ...or deleted one; event = { objectId, itemId, position, rotation, scale, properties, sessionId }
async onDispose(ctx) {}, // room closing - final saves
});Placed objects and inventory
Additional context APIs for mods 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, mod-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<ModServerContext, 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 }
// requires 'world.entities.read'; all values are detached read-only snapshots
await ctx.world.entities.get({ kind: 'vehicle', id: vehicleId });
await ctx.world.entities.query({ kinds: ['vehicle'], withComponents: ['Seat'], limit: 32 });
await ctx.world.entities.findNearby(player.position, 20, { withTags: ['unoccupied'] });The facade projects players, vehicles, world objects, pickups, bots, cranes, and mod entities onto one engine-neutral EntityRef. It never returns a Colyseus schema, Three.js object, or mutable physics handle. Player rows are included only when the mod also declares players.read. Queries are bounded to 256 results and a 2,048 m radius, at 10/s with burst 20.
ctx.zones and ctx.interactions
const zoneId = await ctx.zones.create({
id: 'delivery',
shape: { type: 'box', center: { x: 10, y: 1, z: 4 }, halfExtents: { x: 3, y: 2, z: 3 } },
});
const interactionId = await ctx.interactions.register({
id: 'terminal',
label: 'Use terminal',
target: { type: 'position', position: { x: 10, y: 1, z: 4 } },
range: 2.5,
holdDurationMs: 500,
cooldownMs: 1000,
playerModes: ['walk'],
});The host owns membership and interaction validation. A client completion is delivered to onInteraction only after current target existence, player mode, 3D distance, hold duration, cooldown, rate limit and operation replay checks.
ctx.activities
const definitionId = await ctx.activities.register({
id: 'delivery', version: 1, title: 'Delivery', mode: 'coop',
minPlayers: 2, maxPlayers: 4, persistence: 'full',
startNode: 'collect',
nodes: [
{ id: 'collect', type: 'objective', objectiveId: 'collect', label: 'Collect cargo', next: 'wait' },
{ id: 'wait', type: 'delay', durationMs: 500, next: 'done' },
{ id: 'done', type: 'complete' },
],
});
const activity = definitionId
? await ctx.activities.create(definitionId, { sessionIds: ['session-a', 'session-b'] })
: null;
if (activity) await ctx.activities.start(activity.id);Supported base nodes are set, emit, branch, objective, delay, complete, and fail. mod:<node> pauses at an isolated custom node until the server mod calls resolveCustomNode. Full/checkpoint snapshots live in a host-reserved namespace; reconnect binds an opaque stable principal to the new session without exposing raw engine or database objects.
ctx.entities - replicated objects world.entities
const id = ctx.entities.spawn({
id: 'chest-0', // optional stable suffix → 'my-mod: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
components?: { // must be declared by manifest.components
'cargo-state': { secured: false }
},
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();ctx.entities.update(id, { components: { 'cargo-state': { secured: true } } }) validates and updates a component; a null value removes it. Component IDs are local in the owning API and appear as mod:<modId>:<componentId> in cross-world query metadata. Components and properties share the 2 KB entity data budget. Quota: 384 entities per mod per world. Transforms are binary delta-encoded; cold data should change rarely. Dynamic bodies publish at 10 Hz.
ctx.physics - authoritative body actions and joints
The host owns every native body and joint. Mods use stable entity/joint ids; no native physics object crosses the SDK boundary.
ctx.physics.applyImpulse(bodyId, { x: 0, y: 4, z: 0 });
ctx.physics.applyForce(bodyId, { x: 20, y: 0, z: 0 }, worldPoint);
ctx.physics.applyTorque(bodyId, { x: 0, y: 3, z: 0 });
ctx.physics.setVelocity(bodyId, { x: 2, y: 0, z: 0 });
const body = await ctx.physics.getBodyState(bodyId); // detached transform/velocity/mass/awake snapshot
// Presentation priority only: server authority never transfers.
const lease = await ctx.physics.acquireControl(bodyId, playerSessionId, 1500);
await ctx.physics.releaseControl(bodyId, playerSessionId);
const jointId = await ctx.physics.createJoint({
id: 'trailer-hinge',
type: 'hinge', // fixed | hinge | ball
entityA: tractorId,
entityB: trailerId,
anchorA: { x: 0, y: 0, z: -1 },
anchorB: { x: 0, y: 0, z: 1 },
axis: { x: 0, y: 1, z: 0 },
limits: [-0.7, 0.7],
});Body actions require physics.colliders; joint lifecycle requires the separate physics.joints capability. Both endpoints must be live dynamic entities owned by the caller. There are at most 128 joints per mod and 120 physics mutations per second (burst 240). Removing either endpoint, disabling the mod, or disposing the room destroys the native joint first. Current lifecycle facts are created and removed; collision and break events remain unavailable until both physics backends expose the same authoritative telemetry.
Control leases require a connected session, last 100–5000 ms (750 ms by default), reject contention from another session, and expire on the host clock. They select solver-rate replication and client reconciliation priority; they do not allow the client to mutate or simulate the authoritative body. Disconnect, entity/mod cleanup and room disposal revoke them automatically. Renew a lease while an interaction is active instead of storing a permanent owner id.
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 useModState(). 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/mod/world
await ctx.storage.delete(key);Namespaced per mod per world, survives restarts. Awaited I/O does not count against your tick budget.
ctx.messages
For a mod with manifest messageSchemas, generate message maps and use the typed server definition:
import { defineTypedModServer } from '@vibelands/mod-sdk';
import type {
ClientToServerMessages,
ServerToClientMessages,
} from '../shared/mod-messages.generated';
export const server = defineTypedModServer<
ClientToServerMessages,
ServerToClientMessages
>({
onMessage: {
openChest(ctx, client, data) {
// client.operationId is present for delivery: "at-most-once" intents.
// data is the generated openChest payload type.
ctx.messages.send(client.sessionId, 'chestResult', {
ok: true,
rewardCents: 250,
});
},
},
});Message names and payloads are checked by TypeScript and again by the host. The same validation applies to in-process server hooks and isolated hosted RPC workers. Schema validation establishes transport shape; the mod must still validate authority and gameplay semantics.
The host suppresses a repeated at-most-once operation before this handler is called. This is a bounded replay window, not durable response caching; persist business-level idempotency keys when correctness must survive worker restarts.
The compatibility API remains available:
ctx.messages.send(sessionId, 'questOffered', { questId });
ctx.messages.broadcast('chestOpened', { chestId, byName });Received on the client via useModMessage(type, handler).
ctx.resources
Register any custom subscription, external handle, or disposable that is not already owned by ctx.schedule or ctx.entities:
ctx.resources.defer(() => unsubscribe(), 'presence-subscription');
const child = ctx.resources.child('round-1');
child.own(controller, (value) => value.dispose(), 'round-controller');The scope runs active disposers once, in reverse order, after onDispose on normal stop, room shutdown, or circuit-breaker disable. Cleanup is best-effort: one failure is reported but does not prevent later resources from releasing. The same API exists inside isolated hosted workers and the headless harness.
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 call
const paid = await ctx.rewards.spendMoney(sessionId, 400, 'Stall purchase'); // false if the player can't afford itPersists 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, () => { ... });Both methods return a cancellation function. Timers are cleared when your mod is disabled or the room closes. Use ctx.now() for server timestamps so the test harness can control time.
ctx.log
ctx.log('6 chests placed'); // → [mod: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 rate-limits (20 msg/s per client, burst 40, ≤ 8 KB) and, for declared directions, blocks unknown names and schema-invalid payloads. Shape validation does not prove ownership, distance, cooldown, cost, replay safety, or any other gameplay rule—those semantics are still yours to defend.
Confirmed runtime creation (experimental)
The current development SDK exposes await ctx.operations.command('entity.spawnOwned', init, { operationId }). It returns Result<string> after the host creates the entity and optional physics body. It requires world.entities, plus physics.colliders for a physics descriptor. The manifest remains API v2; use a matching host/SDK build. Unlike the legacy worker ctx.entities.spawn(), success is host-confirmed.
Retry a lost acknowledgement with the same operation ID and payload within five minutes. A different payload conflicts. Spawn/destroy share 2048 runtime receipts; these do not survive restart. Replay acknowledges the original creation even after removal; query entity.getOwned to check current state. Temporary entities are cleaned up on stop. A client layer must render them; persisted editor placement and stored activity state are separate concerns.