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
| Change | Where | |
|---|---|---|
| 🔴 | Room join / leave moved to the SDK | client + server.js |
| 🔴 | Room state is no longer reachable through $global | server.js |
| 🔴 | CROSS Mini Hub helpers removed from $global | server.js |
| 🔴 | getCollectionItem throws when the item is missing | server.js |
| 🟡 | Collection item ids: item.__id → options.id | server.js |
| 🟡 | Global collection writes have a ~2s durability window | server.js |
| 🟡 | Disconnected room users get a 30s grace period | server.js |
| 🟡 | $sender.isSubscriber no longer checks expiry | server.js |
| 🟢 | Partial reads, batch reads, clear APIs, richer connection status | additive |
🔴 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.
npm install @agent8/gameserver@latest
# confirm what you actually got — 2.0 is the 2.x line
npm ls @agent8/gameserverIf 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:
// 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.
// 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.
// 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:
| Data | Use |
|---|---|
| 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, rankings | Global 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:
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:
| Removed | Replacement |
|---|---|
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?). clearAPIs —clearGlobalState,clearUserState,clearMyState,$room.clear*,$asset.clear.$asset—get/getAll/getMany/has/mint/burn/cleartake an optional trailingaccount(transferdoes 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,currentRoomIdfromuseGameServer(), and theConnectionStatus/ConnectionPhasetypes plusisTerminalCloseCode/isTransientCloseCodehelpers.
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 rsConnectedThen verify by hand:
- Rooms are entered through
joinRoom(roomId)and room screens gate onrsConnected. - 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
getCollectionItemcall handles the not-found throw. - No currency, item or reward write depends on a global collection alone.