Skip to content

Tier B: Gameplay Mod SERVER-AUTHORITATIVE ​

This walkthrough explains the parts of a treasure hunt: server-placed chests, validated requests, storage, and a shared scoreboard. The snippets are teaching examples. For a generated project with tests, start with:

bash
vibelands create my-treasure --template persistent-collectible

Persistence and rewards

The simplified handler below performs separate save and reward calls. It is not an atomic claim transaction. Check their boolean results and design retry/recovery behavior before using it for valuable rewards. See State and saving.

The authoritative loop ​

server spawns chest entities ──► replicated to every client
client renders them, player walks up and clicks a chest
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.read", "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, defineModServer, type ModServerContext } from '@vibelands/mod-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<ModServerContext, RoomState>();

export const server = defineModServer({
  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 = [];
    // Some worlds have no suitable dry land: keep the search bounded.
    for (let attempt = 0; chests.length < count && attempt < count * 20; attempt += 1) {
      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] === undefined) {
        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] !== undefined) 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] = ctx.now();
      const saved = await ctx.storage.set('opened', state.opened);
      if (!saved) {
        delete state.opened[chestId];
        ctx.log('Chest claim was not saved', chestId);
        return;
      }
      ctx.entities.remove(`${ctx.modId}:${chestId}`);
      const rewarded = await ctx.rewards.grantMoney(
        client.sessionId, Number(ctx.config.rewardCents ?? 2500), 'Treasure found',
      );
      if (!rewarded) ctx.log('Claim saved but reward failed; recovery needed', chestId);
      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 {
  useModEntityList,
  useModMessage,
  useSendModMessage,
} from '@vibelands/mod-sdk/client';

export function TreasureLayer() {
  const chests = useModEntityList();          // live: [{ id, kind, position, ... }]
  const send = useSendModMessage();

  useModMessage('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.slice(chest.id.indexOf(':') + 1) })}
      >
        <boxGeometry args={[1, 0.7, 0.7]} />
        <meshStandardMaterial color="#8a5a2b" />
      </mesh>
    </group>
  ));
}

And a HUD scoreboard from the replicated KV blob:

jsx
import { useModState } from '@vibelands/mod-sdk/client';

export function TreasureHud() {
  const score = useModState();                // { 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
import { defineModClient } from '@vibelands/mod-sdk/client';
import { TreasureLayer } from './TreasureLayer';
import { TreasureHud } from './TreasureHud';

export default defineModClient({ 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, this example strips only the first namespace prefix. Alternatively, keep full IDs on both sides and validate them against your owned entities.

4. Test the whole loop headlessly ​

This exact gameplay loop - placement, validation, rewards, persistence - runs in milliseconds under the testing SDK:

ts
import { expect, it } from 'vitest';
import { createModTestHarness } from '@vibelands/mod-sdk/testing';
import { server } from './index';

it('rewards a nearby player only once', async () => {
  const harness = createModTestHarness(server, {
    modId: 'my-treasure',
    permissions: ['world.entities', 'storage', 'players.read', 'players.modify'],
    config: { chestCount: 4 },
    world: { getGroundHeight: () => 6, seaLevel: 0 },
});
await harness.start();
const chest = harness.entities.list()[0];
harness.join({ sessionId: 'p1', position: chest.position });
await harness.message('openChest', { chestId: chest.id.slice(chest.id.indexOf(':') + 1) }, 'p1');
expect(harness.rewards[0].moneyCents).toBe(2500);
await harness.message('openChest', { chestId: chest.id.slice(chest.id.indexOf(':') + 1) }, 'p1');
expect(harness.rewards).toHaveLength(1);
await harness.dispose();
});

Gotchas the host protects you from (but logs) ​

  • Quotas: 384 entities/mod, 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 mod is disabled for that room.

VibeLands Creator · Runtime API v2