Skip to Content
Game ServerServer Globals

Server Globals

Your server code runs with a set of predefined variables and functions already in scope — you never import or construct them. This page applies to both server formats: the same globals are available whether your code lives in a root server.js or in a Structured Project’s server/src/server.ts. Examples throughout these docs use the structured form, export class Server in server/src/server.ts; in a root server.js, drop the export.

State Management

  • $global - Global state, per-user state and global collections, shared across all rooms.

    // Read the whole state, or only the dot-notation paths you need const state = await $global.getGlobalState(); const subset = await $global.getGlobalState(['stats.hp', 'profile.level']); // subset === { stats: { hp: 100 }, profile: { level: 12 } } // Shallow-merge top-level fields into the state await $global.updateGlobalState({ key: "value" }); // Delete top-level fields — no argument wipes every field await $global.clearGlobalState(['seasonId']); await $global.clearMyState(); // the caller's user state await $global.clearUserState(otherAccount); // another user's state

    Full reference: Global State and Global User State.

  • $room - State for the room the caller is in. Usable only after the client has joined a room with joinRoom(roomId) from useGameServer().

    const state = await $room.getRoomState(); const subset = await $room.getRoomState(['session.status']); // subset === { roomId: 'roomA', session: { status: 'running' }, $users: [...] } await $room.updateRoomState({ key: "value" }); // Wiping keeps roomId and $users await $room.clearRoomState(['matchPhase', 'turnCount']); await $room.clearMyState(); // the caller's room-user state await $room.clearUserState(otherAccount); // another user in the room

    Full reference: Room State and Room User State.

  • $asset - Fungible per-account balances (in-game currencies, resources, season tokens).

    const gold = await $asset.get("gold"); const currencies = await $asset.getMany(["gold", "gems"]); // absent ids are omitted // Server-decided amounts only — never pass client input straight through await $asset.mint("gold", 100); await $asset.burn("gold", 50); await $asset.transfer(otherAccount, "gold", 10); await $asset.clear(["season_token"]); // no argument wipes every asset

    Every method except transfer takes an optional trailing account to act on another user. Full reference: Asset.

  • $lock - Function that serializes a read-modify-write so two concurrent requests cannot interleave.

    // Acquires the lock, runs the function, then releases it — even if it throws. const reward = await $lock(`daily:${$sender.account}`, async () => { const { lastClaim } = await $global.getMyState(['lastClaim']); const today = new Date().toISOString().slice(0, 10); if (lastClaim === today) return { granted: false }; await $asset.mint('gold', 100); await $global.updateMyState({ lastClaim: today }); return { granted: true }; });

    Signature, timeout options and the cases where a lock is unnecessary overhead: Distributed Lock.

Fire-and-forget writes

Every update* on $global and $room resolves with the patch object you passed — not the merged document — so never read a field you did not write out of the result. When you ignore the result anyway, pass { returnState: false }: the write is identical and the call resolves with null, which saves copying the patch back on a hot path.

server/src/server.ts
export class Server { async $roomTick() { // The tick never reads the result, so don't pay to ship it back. await $room.updateRoomState({ frame: Date.now() }, { returnState: false }); } }

All six state writers accept the option: $global.updateGlobalState / updateUserState / updateMyState and $room.updateRoomState / updateUserState / updateMyState. Two caveats for a Structured Project: the published TypeScript declarations do not yet type it on $global.updateGlobalState and $global.updateUserState, so those two need a cast, and the local test runner ignores it on updateGlobalState and returns the state.

Request Context

  • $sender - Who made the current request.

    const { account, roomId, isGuest, isFollower, isSubscriber, subscription } = $sender;
    • account - User’s wallet address (0x…, Verse8 embedded EOA)
    • roomId - The caller’s current room ID, or the sentinel string '__no_room__' outside a room. It is never null or empty, so if ($sender.roomId) is always true — test $sender.roomId === '__no_room__'. Room-scoped code ($roomTick, the onRoom* hooks) always sees a real room ID. ($room.getId() is no alternative: it throws outside a room.)
    • isGuest - true if the user is not authenticated via Verse8
    • isFollower - Whether the user follows the creator
    • isSubscriber - Whether the token carried a subscription at all — it does not check expiry, so an expired subscriber is still true. To gate on a live subscription: $sender.subscription && $sender.subscription.exp * 1000 > Date.now()
    • subscription - { tier: string, exp: number } or null. The tier is 'basic' today; only trusted from server-signed authentication tokens.
    • externalWallet - Linked on-chain wallet { type: 'abstractSmartWallet', address: string }, a separate identity from account. Falsy when there is none — null for guests, undefined in webhook handlers such as $onItemPurchased — so test it with if ($sender.externalWallet).
    ⚠️

    $sender is not a player everywhere. The properties above describe a normal client call. Two other entry points hand you a different shape:

    • In $roomTick, $sender.account is the literal string '$system' — there is no calling player. $sender.isTick is present and true here, and absent (undefined) everywhere else, so test it with if ($sender.isTick), not === false.
    • In the onRoom* lifecycle hooks, $sender.isGuest is undefined — the key is not set at all. In onRoomCreate / onRoomDestroy the account is also '$system'; in onRoomJoin / onRoomDisconnect / onRoomLeave it is the real player, but you should use the account argument the hook is given rather than $sender.

    Code that gates on $sender.isGuest or bills $sender.account will therefore misbehave if it also runs on a tick or a hook. Guard those paths explicitly.

Recurring Logic

  • $roomTick(deltaMillis, roomId) - Runs every 200 ms for every active room. Optional — define it only if you need server-driven recurring logic.

    $roomTick(deltaMillis, roomId) { // deltaMillis: time since the previous tick } // Optional: while a room has seen no client activity since the last executed // tick, run at most one tick per this many ms. Must be a number in // 1000..600000 (`= 0` throws when the server code loads) and requires a // $roomTick method; omit it to tick unconditionally. static $roomTickIdleMs = 5000;

    A tick that is still running when the next one is due is skipped rather than queued, so a slow tick lowers the effective rate instead of piling up.

System Event Handlers

Special handlers called automatically by the system. Clients cannot call them — the runtime rejects any function name starting with $on, $roomTick or $room.

  • $onItemPurchased(data) - Called when a user purchases an item from VX Shop

    PropertyTypeDescription
    accountstringUser’s wallet address
    purchaseIdnumberUnique purchase ID
    productIdstringProduct identifier from VX Shop
    quantitynumberNumber of items purchased
    metadataobject?Optional additional data
    🚫

    A handler that throws is retried — make the body idempotent. The platform dedupes by purchaseId only while a claim is PROCESSING or already COMPLETED. A claim whose handler threw is recorded as FAILED, and a FAILED claim may be retried — the retry re-runs your handler from the top with the same purchaseId. So a handler that grants several things must never leave a partial grant behind: either make every grant individually re-runnable, or record progress per item and skip what you already granted. A single marker written at the end of the handler does not cover a throw part-way through — nothing was recorded, so the retry grants again from the top. Never put an unconditional $asset.mint after something that can throw.

    If you serialize the body with $lock, pass a short wait. The purchase webhook runs the handler with a 5s execution budget, and $lock’s default maxWaitMs is also 5000 — so a contended key can spend the whole budget waiting and then throw $lock timeout, which records the claim FAILED and triggers the very retry you are guarding against. Use $lock(key, fn, { maxWaitMs: 1500 }).

    async $onItemPurchased({ account, purchaseId, productId, quantity }) { // Skip if an earlier attempt already finished granting this purchase — a claim can be // recorded FAILED after the grants applied, and a FAILED claim may be retried. const granted = (await $global.getMyState(['grantedPurchases'])).grantedPurchases || {}; if (granted[purchaseId]) return { success: true }; // Grant items based on productId switch (productId) { case 'gold_pack': await $asset.mint('gold', 100 * quantity); break; case 'premium_skin': const user = await $global.getMyState(['skins']); await $global.updateMyState({ skins: [...(user.skins || []), productId] }); break; default: await $asset.mint(productId, quantity); } // Marked last, so a retry after a fully-applied claim is a no-op. await $global.updateMyState({ grantedPurchases: { ...granted, [purchaseId]: true }, }); return { success: true }; }

    Inside $onItemPurchased, $sender.account is the buyer, so $asset and the *MyState helpers already target them.

Execution Model

  • Every Server method is a public endpoint. Any client can call it by name, with any arguments. Only the reserved names are refused (see above, and Reserved names) — a $ prefix alone protects nothing, so $applyDamage is callable like any other method. Take identity from $sender, decide amounts and targets on the server, and never pass an unchecked argument straight into updateUserState, $asset.mint or a collection write.
  • Each request creates a new Server instance. Class-level variables reset per request — use them only for constants. Anything that must survive a call belongs in $global, $room or $asset.
  • No timers. setTimeout and setInterval are unavailable in the isolated VM; use $roomTick for recurring work.
Last updated on