Tier B: Gameplay Plugin SERVER-AUTHORITATIVE
Build a treasure hunt: server-placed chests, validated opens, money rewards, persistence across restarts, and a live scoreboard. This walkthrough mirrors the first-party treasure-hunt reference plugin.
The authoritative loop
server spawns chest entities ──► replicated to every client
client renders them, player walks up, presses E
client sends 'openChest' ──────► server VALIDATES (exists? alive? close enough?)
server removes entity, persists, pays reward, broadcasts 'chestOpened'
every client sees the chest vanish + the scoreboard tick upThe client requests; the server decides. Never trust the client.
1. Manifest - declare what you need
{
"id": "my-treasure",
"name": "My Treasure",
"version": "0.1.0",
"apiVersion": 2,
"tier": "B",
"entries": { "client": "client/index.jsx", "server": "server/index.ts" },
"permissions": ["world.entities", "storage", "players.modify", "ui.hud"],
"configSchema": {
"type": "object",
"properties": {
"chestCount": { "type": "integer", "default": 6, "minimum": 1, "maximum": 24 },
"rewardCents": { "type": "integer", "default": 2500, "minimum": 100, "maximum": 100000 }
}
}
}Undeclared permissions are denied at runtime (logged no-ops), so declare exactly what you use.
2. Server entry
import { createMulberry32, definePluginServer, type PluginServerContext } from '@vibelands/plugin-sdk';
interface RoomState {
chests: Array<{ id: string; x: number; y: number; z: number }>;
opened: Record<string, number>; // chestId -> openedAt
}
// One state per ROOM - module-level variables would leak across worlds.
const roomStates = new WeakMap<PluginServerContext, RoomState>();
export const server = definePluginServer({
tickIntervalMs: 1000,
async onWorldStart(ctx) {
// Deterministic placement from the world seed - same chests every boot.
const rng = createMulberry32(ctx.world.terrainSeed >>> 0);
const count = Number(ctx.config.chestCount ?? 6);
const chests = [];
while (chests.length < count) {
const x = (rng() * 2 - 1) * ctx.world.terrainSize * 0.35;
const z = (rng() * 2 - 1) * ctx.world.terrainSize * 0.35;
const ground = ctx.world.getGroundHeight(x, z);
if (ground < ctx.world.seaLevel + 0.6) continue; // dry land only
chests.push({ id: `chest-${chests.length}`, x, y: ground, z });
}
// What survived previous sessions?
const opened = (await ctx.storage.get<Record<string, number>>('opened')) ?? {};
roomStates.set(ctx, { chests, opened });
for (const chest of chests) {
if (!opened[chest.id]) {
ctx.entities.spawn({ id: chest.id, kind: 'chest', position: chest });
}
}
ctx.state.set({ total: chests.length, opened: Object.keys(opened).length });
},
onMessage: {
async openChest(ctx, client, data) {
const state = roomStates.get(ctx);
if (!state) return;
// VALIDATE EVERYTHING - `data` is hostile input.
const chestId = String((data as { chestId?: unknown })?.chestId ?? '');
const chest = state.chests.find((entry) => entry.id === chestId);
if (!chest || state.opened[chestId]) return;
const player = ctx.players.get(client.sessionId);
if (!player || player.dead) return;
const dx = player.position.x - chest.x;
const dz = player.position.z - chest.z;
if (dx * dx + dz * dz > 4.5 * 4.5) return; // must be close
state.opened[chestId] = Date.now();
ctx.entities.remove(`${ctx.pluginId}:${chestId}`);
await ctx.storage.set('opened', state.opened); // survives restarts
await ctx.rewards.grantMoney(client.sessionId, Number(ctx.config.rewardCents ?? 2500), 'Treasure found');
ctx.messages.broadcast('chestOpened', { chestId, byName: player.name });
ctx.state.set({ total: state.chests.length, opened: Object.keys(state.opened).length });
},
},
});
export default server;The context is your whole world
ctx.entities (replicated objects) · ctx.state (replicated KV) · ctx.storage (persistent DB) · ctx.messages (send/broadcast) · ctx.rewards (money/XP) · ctx.schedule (host-owned timers) · ctx.players (snapshot views) · ctx.world (terrain/time/weather). Full surface: Server SDK.
3. Client - render entities, send intents
import {
usePluginEntityList,
usePluginMessage,
usePluginState,
useLocalPlayerGetter,
useSendPluginMessage,
} from '@vibelands/plugin-sdk/client';
export function TreasureLayer() {
const chests = usePluginEntityList(); // live: [{ id, kind, position, ... }]
const send = useSendPluginMessage();
const getLocalPlayer = useLocalPlayerGetter();
usePluginMessage('chestOpened', ({ byName }) => {
console.log(`${byName} found treasure!`);
});
return chests.map((chest) => (
<group key={chest.id} position={[chest.position.x, chest.position.y, chest.position.z]}>
<mesh
name={chest.id}
onClick={() => send('openChest', { chestId: chest.id.split(':')[1] })}
>
<boxGeometry args={[1, 0.7, 0.7]} />
<meshStandardMaterial color="#8a5a2b" />
</mesh>
</group>
));
}And a HUD scoreboard from the replicated KV blob:
import { usePluginState } from '@vibelands/plugin-sdk/client';
export function TreasureHud() {
const score = usePluginState(); // { total, opened } - set by the server
if (!score) return null;
return (
<div style={{ position: 'absolute', top: 80, right: 16, pointerEvents: 'none' }}>
🪙 {score.opened}/{score.total}
</div>
);
}export default definePluginClient({ WorldLayer: TreasureLayer, HudPanel: TreasureHud });Entity id round-trip
Clients receive namespaced ids (my-treasure:chest-0). When sending the id back to your server handler, strip the prefix: id.split(':')[1].
4. Test the whole loop headlessly
This exact gameplay loop - placement, validation, rewards, persistence - runs in milliseconds under the testing SDK:
const harness = createPluginTestHarness(server, { permissions: [...], config: { chestCount: 4 } });
await harness.start();
const chest = harness.entities.list()[0];
harness.join({ sessionId: 'p1', position: chest.position });
await harness.message('openChest', { chestId: chest.id.split(':')[1] }, 'p1');
expect(harness.rewards[0].moneyCents).toBe(2500);Gotchas the host protects you from (but logs)
- Quotas: 384 entities/plugin, 2 KB entity properties, 8 KB KV blob, 32 KB storage values - the full table.
- Rates: entity transforms batch at 10 Hz, KV at 2/s, inbound messages 20/s per client. Over-rate writes defer (last write wins) - your final state always lands.
- Tick budget: 100 ms of synchronous hook time per 5 s window. Awaited I/O (
ctx.storage) does not count. Trip it 3 windows in a row → your plugin is disabled for that room.