Skip to content

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 up

The client requests; the server decides. Never trust the client.

1. Manifest - declare what you need

json
{
  "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

ts
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

jsx
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:

jsx
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>
  );
}
jsx
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:

ts
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.

VibeLands Creator - Plugin SDK apiVersion 2