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 stateFull 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 withjoinRoom(roomId)fromuseGameServer().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 roomFull 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 assetEvery method except
transfertakes an optional trailingaccountto 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.
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 nevernullor empty, soif ($sender.roomId)is always true — test$sender.roomId === '__no_room__'. Room-scoped code ($roomTick, theonRoom*hooks) always sees a real room ID. ($room.getId()is no alternative: it throws outside a room.)isGuest-trueif the user is not authenticated via Verse8isFollower- Whether the user follows the creatorisSubscriber- Whether the token carried a subscription at all — it does not check expiry, so an expired subscriber is stilltrue. To gate on a live subscription:$sender.subscription && $sender.subscription.exp * 1000 > Date.now()subscription-{ tier: string, exp: number }ornull. The tier is'basic'today; only trusted from server-signed authentication tokens.externalWallet- Linked on-chain wallet{ type: 'abstractSmartWallet', address: string }, a separate identity fromaccount. Falsy when there is none —nullfor guests,undefinedin webhook handlers such as$onItemPurchased— so test it withif ($sender.externalWallet).
⚠️$senderis not a player everywhere. The properties above describe a normal client call. Two other entry points hand you a different shape:- In
$roomTick,$sender.accountis the literal string'$system'— there is no calling player.$sender.isTickis present andtruehere, and absent (undefined) everywhere else, so test it withif ($sender.isTick), not=== false. - In the
onRoom*lifecycle hooks,$sender.isGuestisundefined— the key is not set at all. InonRoomCreate/onRoomDestroytheaccountis also'$system'; inonRoomJoin/onRoomDisconnect/onRoomLeaveit is the real player, but you should use theaccountargument the hook is given rather than$sender.
Code that gates on
$sender.isGuestor bills$sender.accountwill 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 ShopProperty Type Description 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
purchaseIdonly 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 samepurchaseId. 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.mintafter 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 defaultmaxWaitMsis 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.accountis the buyer, so$assetand the*MyStatehelpers already target them.
Execution Model
- Every
Servermethod 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$applyDamageis callable like any other method. Take identity from$sender, decide amounts and targets on the server, and never pass an unchecked argument straight intoupdateUserState,$asset.mintor a collection write. - Each request creates a new
Serverinstance. Class-level variables reset per request — use them only for constants. Anything that must survive a call belongs in$global,$roomor$asset. - No timers.
setTimeoutandsetIntervalare unavailable in the isolated VM; use$roomTickfor recurring work.