Skip to Content
Game ServerUpgrading to 2.0

Upgrading to 2.0

@agent8/gameserver 2.0 changes how rooms are joined and removes a group of $global methods. Most game code needs only a handful of edits — this page lists every change that can break an existing project, in the order you should check it.

Client hooks are unchanged. Every hook 1.x exported (useGameServer, useGlobalState, useGlobalMyState, useGlobalUserState, useGlobalCollection, useRoomState, useRoomMyState, useRoomUserState, useRoomAllUserStates, useRoomCollection, useAsset, GameServerProvider) still exists with the same name and the same return shape. The one exception is useGameServer(), which gains connectionStatus, rsConnected, currentRoomId, joinRoom and leaveRoom — nothing was removed, so an existing destructure keeps working.

At a glance

ChangeWhere
🔴Room join / leave moved to the SDKclient + server.js
🔴Room state is no longer reachable through $globalserver.js
🔴CROSS Mini Hub helpers removed from $globalserver.js
🔴getCollectionItem throws when the item is missingserver.js
🟡Collection item ids: item.__id → options.idserver.js
🟡Global collection writes have a ~2s durability windowserver.js
🟡Disconnected room users get a 30s grace periodserver.js
🟡$sender.isSubscriber no longer checks expiryserver.js
🟢Partial reads, batch reads, clear APIs, richer connection statusadditive

🔴 fails loudly (a thrown error or TypeError). 🟡 keeps running and behaves differently — these are the ones to read carefully.

0. Install 2.0

Step zero is the dependency bump. Everything below is written against the 2.0 package, and the SDK entry points it relies on — joinRoom / leaveRoom / rsConnected from useGameServer() — do not exist in 1.x.

terminal
npm install @agent8/gameserver@latest # confirm what you actually got — 2.0 is the 2.x line npm ls @agent8/gameserver

If your project also pins the server tooling in devDependencies — structured projects do, in server/package.json — bump @agent8/gameserver-node to 2.x as well. It shares the client SDK’s major version; patch releases of the two can differ. A project that only ever runs npx -y @agent8/gameserver-node … and has no @agent8/gameserver-node entry in its package.json picks up the new version automatically.

1. Room join and leave

The server no longer joins rooms. $global.joinRoom(), $global.leaveRoom(), $room.join() and $room.leave() all throw. Your server function returns a roomId, and the client passes it to the SDK’s joinRoom.

Server — return the id instead of joining:

server.js
// 1.x class Server { async joinRoom(roomId) { return await $global.joinRoom(roomId); // throws in 2.0 } } // 2.0 — return a roomId from a function of your choosing // (any name except `joinRoom` / `leaveRoom` — see the warning below). class Server { async findMatch() { const roomIds = await $global.getAllRoomIds(); for (const roomId of roomIds) { if ((await $global.countRoomUsers(roomId)) < 4) return roomId; } return 'Room' + Math.random().toString(16).substring(2, 10); } }

Client — call the matching function, then joinRoom:

// 1.x const { connected, server } = useGameServer(); server.remoteFunction("joinRoom", [roomId]); // 2.0 const { connected, server, joinRoom, leaveRoom, rsConnected, currentRoomId } = useGameServer(); const roomId = await server.remoteFunction("findMatch", []); await joinRoom(roomId);

joinRoom is what connects the client to the room, so it cannot be replaced by a remoteFunction call. See Room System for the full flow.

⚠️

Rename any server function called joinRoom or leaveRoom. The 2.0 SDK rejects those two names in remoteFunction() before anything is sent, so a server function that keeps either name is registered but permanently unreachable. If yours was just a pass-through to $global.joinRoom(roomId), delete it — the SDK’s joinRoom(roomId) replaces it entirely. If it did real matchmaking, rename it (findMatch, createRoom, …) and call the new name.

⚠️

Gate room UI on rsConnected, not connected. connected only says the client reached the server; $room state and useRoomState() are not available until rsConnected is true. Any if (!connected) return <Loading/> guarding a room screen should become if (!rsConnected).

2. Room state through $global

In 1.x you could read and write any room’s state from $global by passing a roomId. In 2.0 room state lives behind $room, which always operates on the room the caller is in — there is no roomId argument.

Removed from $global (1.x)2.0
getRoomState(roomId)$room.getRoomState()
updateRoomState(roomId, state)$room.updateRoomState(state)
getRoomMyState(roomId) / updateRoomMyState(roomId, state)$room.getMyState() / $room.updateMyState(state)
getRoomUserState(roomId, account) / updateRoomUserState(roomId, account, state)$room.getUserState(account) / $room.updateUserState(account, state)
getRoomAllUserStates(roomId)$room.getAllUserStates()
getAllRoomStates()$room.getAllRoomStates()
currentRoomId()$sender.roomId — not a plain rename: outside a room this is the string '__no_room__', not null, so a if (roomId) check that used to be false is now true

These still work on $global and are callable from anywhere, in or out of a room: countRooms(), getAllRoomIds(), getRoomUserAccounts(roomId), countRoomUsers(roomId), destroyRoom(roomId).

⚠️

destroyRoom(roomId) does less than it did in 1.x. It now clears room membership only. It does not fire onRoomDestroy, does not delete the room’s collections, and does not disconnect clients — they stay connected believing they are still in the room. Called from inside the room it also deletes the room state; called from outside, the room state survives. If you used it to reset a room between matches, clear the state yourself ($room.clearRoomState()) and tell the clients to leave via a broadcast rather than relying on this call.

⚠️

A lobby that read other rooms’ state needs redesign, not a rename. Room state is only readable from inside the room: $room.getAllRoomStates() called outside one returns only roomId and $users, with custom fields silently empty, so a lobby that listed state.mapName renders blanks. Have the room mirror what the lobby needs into global storage from its hooks — see Room Discovery. $global is reachable from inside and outside a room, so it is also how one room reads another’s data.

3. Room lifecycle hooks

1.x had no server-side room lifecycle hooks. 2.0 calls five optional ones — onRoomCreate, onRoomJoin, onRoomDisconnect, onRoomLeave, onRoomDestroy — and they are where room setup and cleanup belong now that the server no longer drives joining. See Room System.

Disconnects now have a 30-second grace period. In 1.x a dropped connection removed the user immediately. In 2.0 the user stays in $users, $roomTick keeps running, and onRoomLeave fires only if they do not come back within 30 seconds — and an explicit leaveRoom() waits the same way. Closing the tab still leaves at once.

So if your game reacts to a player vanishing — substituting an AI, awarding the win, freeing a slot — that reaction can now happen up to 30 seconds later. Keep it in onRoomLeave, the only hook that runs on every exit; onRoomDisconnect does not fire when the tab closes. The full matrix is in Leaving and the Grace Period.

4. Collection item ids

1.x let you choose an item id by setting __id on the item. 2.0 takes it as a separate options argument and ignores __id inside the item.

server.js
// 1.x — honored await $global.addCollectionItem("players", { __id: account, score: 0 }); // 2.0 — the __id is ignored and a fresh id is generated instead await $global.addCollectionItem("players", { __id: account, score: 0 }); // ⚠️ wrong id // 2.0 — correct await $global.addCollectionItem("players", { score: 0 }, { id: account });
⚠️

This one does not throw. The write succeeds under a generated id, so a findOne/get by the id you expected returns nothing and your code reads as “the player has no record” — and re-running the same code creates duplicates instead of updating. Grep for __id in your server code before upgrading.

Upsert semantics differ too: 1.x merged fields into an existing __id, while 2.0’s options.id replaces the whole document. If you relied on the merge, read the item first and spread it, or use updateCollectionItem.

5. getCollectionItem on a missing item

In 1.x a miss came back as an empty object, so the idiomatic existence check was if (!item.__id). In 2.0 the call throws — Item <id> not found in collection <cid> — and that check never runs: the rejection escapes your remote function instead.

server.js
// 1.x — a miss returned {}, so this was the existence check const existing = await $global.getCollectionItem("players", account); if (!existing.__id) { /* create it */ } // 2.0 — the read itself throws on a miss let item = null; try { item = await $global.getCollectionItem("players", account); } catch (err) { // Only a genuine miss may reach the create path — see the warning below. if (!String(err).includes("not found")) throw err; item = await $global.addCollectionItem("players", { score: 0 }, { id: account }); }

$room.getCollectionItem() behaves the same way (Item <id> not found in room <roomId> collection <cid>). Where a miss is a normal outcome rather than an error, catch it — or read through getCollectionItems, which returns an empty array instead of throwing.

⚠️

Do not let a bare catch fall straight through to the create path. addCollectionItem with options.id is an upsert that replaces the whole document (section 4), so if the read failed for any reason other than a miss, the recovery write resets an existing player’s record to the new item’s fields and drops everything else. The runtime exposes no error code for “not found” — the message is the only signal — so match it before creating and re-throw anything else, as above.

6. Global collection durability

In 1.x addCollectionItem wrote through to storage before returning. In 2.0 global collection writes land in a buffer and are committed on a ~2 second throttle. Reads see a buffered write at once and the buffer survives a game-server restart, but a failure of the verse’s in-memory store loses up to 2 seconds of writes.

This is fine for logs, replays, match history and leaderboard entries you can recompute. It is not safe for anything a player would notice losing:

DataUse
Currency, items, rewards$asset — applied atomically on the call (persisted on a 5s throttle; clear persists synchronously)
Unrecoverable progression$global user state
History, logs, replays, rankingsGlobal collections ($global.addCollectionItem)

Two related notes. updateCollectionItem on an item that does not exist never fails on the deployed runtime: it creates a document holding only the fields you passed plus __id. (The local test runner is stricter and throws Item <id> not found in collection <cid>, so a local failure is not the deployed behavior.) And countCollectionItems does not include buffered writes until they flush.

7. $sender.isSubscriber no longer checks expiry

In 1.x this was isValidSub && sub.exp > now. In 2.0 it is !!credential.sub — whether the token carried a subscription at all. An expired subscriber is now true.

Anything gating premium content on $sender.isSubscriber alone therefore keeps granting it after the subscription lapses. The expiry is still available, so check it yourself:

server.js
const sub = $sender.subscription; const isActive = !!sub && sub.exp * 1000 > Date.now();

8. CROSS Mini Hub and other removed $global methods

These were removed from $global with no drop-in replacement:

RemovedReplacement
createCrossRampShopUrl(), generateShopUrl()server.getCrossRampShopUrl(lang) — on the client, not in server code. See CROSS Mini Hub
subscribeCrossRampStatus(), processCrossRampValidation(), processCrossRampResult(), mintAirdropToken(), getPlayerAssets()Platform REST endpoints — no game-code equivalent, and not covered by these SDK docs
initializeAsset(assetId, amount)$asset.clear([assetId]) then $asset.mint(assetId, amount)
loadUserState(), printThis()Removed; $global.getUserState(account) loads on read

9. What’s new (nothing to change)

Worth adopting after the upgrade, but no existing code breaks:

  • Partial reads — getGlobalState(paths), getUserState(account, paths), getMyState(paths), $room.getRoomState(paths). See Partial Fetch.
  • Batch reads — $global.getUserStates(accounts, paths?), $room.getUserStates(accounts, paths?).
  • clear APIs — clearGlobalState, clearUserState, clearMyState, $room.clear*, $asset.clear.
  • $asset — get / getAll / getMany / has / mint / burn / clear take an optional trailing account (transfer does not — its target is the second account), plus the new $asset.getMany(ids).
  • Fire-and-forget writes — { returnState: false } on the six state writers ($global.updateGlobalState / updateUserState / updateMyState, $room.updateRoomState / updateUserState / updateMyState). The collection updaters take no options object.
  • Connection status — connectionStatus, rsConnected, currentRoomId from useGameServer(), and the ConnectionStatus / ConnectionPhase types plus isTerminalCloseCode / isTransientCloseCode helpers.

Upgrade checklist

# 1. Server code that joins rooms — all four of these throw in 2.0 grep -rnE '\$global\.(joinRoom|leaveRoom)|\$room\.(join|leave)\(' server.js server/src # 2. Room state via $global — removed grep -rnE '\$global\.(getRoomState|updateRoomState|getAllRoomStates|getRoomMyState|updateRoomMyState|getRoomUserState|updateRoomUserState|getRoomAllUserStates|currentRoomId)' server.js server/src # 3. Collection ids — silently wrong, does not throw grep -rn '__id' server.js server/src # 4. Existence checks through getCollectionItem — throws now, returned {} in 1.x grep -rnE 'getCollectionItem\(' server.js server/src # 5. Removed helpers grep -rnE 'initializeAsset|CrossRampShopUrl|generateShopUrl|subscribeCrossRampStatus|processCrossRamp|mintAirdropToken|getPlayerAssets|loadUserState|printThis' server.js server/src # 6. Client: server-side room joining, and room UI gated on `connected` grep -rnE 'remoteFunction\(\s*["'"'"'](joinRoom|leaveRoom)' src grep -rn 'connected' src # room screens should gate on rsConnected

Then verify by hand:

  • Rooms are entered through joinRoom(roomId) and room screens gate on rsConnected.
  • Room setup / cleanup lives in the five lifecycle hooks.
  • Anything that assumed instant removal on disconnect accounts for the 30s grace period.
  • Every caller-supplied collection id goes through options.id.
  • Every getCollectionItem call handles the not-found throw.
  • No currency, item or reward write depends on a global collection alone.
Last updated on