All API reference

@indiegems/gem-web-sdk/server

import { defineServer } from '@indiegems/gem-web-sdk/server';

Install it by URL. It is a public download and needs no account and no credential:

"devDependencies": {
  "@indiegems/gem-web-sdk": "https://downloads.gemarcade.com/sdk/v3.1.2/gem-web-sdk.tgz"
}

npm and pnpm both take a tarball URL and both write its hash into your lockfile, so every install after the first either gets the same bytes or fails. To upgrade, edit the version in the URL and install again.

183 symbols, 183 with descriptions.

Start here

defineServer#

function defineServer(setup: (gem: GemMatchServer) => GemServerBundle, options?: GemServerOptions): GemServerFactory

Declare your dedicated server. Default-export the result.

You pass a setup function; it receives a GemMatchServer — the platform operations, your room, your players — and returns the handlers you want. The credential and its refresh are handled for you, and the SDK is disposed when the match ends.

WHY A FUNCTION RATHER THAN A PLAIN OBJECT: your handlers need to share state. The closure the setup function creates is where your world lives, alongside gem, without threading either through five signatures.

A THROWN ERROR IS NOT SWALLOWED. The platform logs it and keeps serving — except in init, where it fails the match. That is deliberate: a server with a broken handler quietly serving nobody is worse than one that stops.

ParameterTypeDescription
setup(gem: GemMatchServer) => GemServerBundleCalled once. Return the handlers you want from GemServerBundle; omit the rest.
optionsGemServerOptionsPassed through to createGemServer. Normally omit it.

Returns. The value to default-export from your bundle's entry file.

import { defineServer } from '@indiegems/gem-web-sdk/server';

export default defineServer((gem) => {
  let round = 0;
  return {
    async init() {
      const room = await gem.room?.get();
      gem.log(`room has ${room?.members.length ?? 0} player(s)`);
    },
    message(playerId, data, isText) {
      if (isText && data === 'next') gem.broadcast(`round ${++round}`);
    },
    async shutdown() {
      await gem.match.end();
    },
  };
});

Guides. Writing the server for a dedicated match

GemMatchServer#

interface GemMatchServer

The gem your bundle holds — everything a dedicated server can do, on one object.

This is what defineServer hands your setup function. It is a GemServer — the platform operations, your room, ending the match — with the match surface added: the players connected right now, and how to talk to them.

The allocation is spelled ONE way. There is no gem.matchId; it is gem.identity.matchId.

export default defineServer((gem) => ({
  join(playerId) {
    const who = gem.profile(playerId);
    gem.broadcast(`${playerId} joined (seat ${gem.playerNumber(playerId)})`);
    gem.log(`now ${gem.players().length} connected`);
  },
}));

GemMatchServer.identity#

readonly identity: GemServerIdentity

Which game, channel and match this server is running.

GemMatchServer.client#

readonly client: GemClient

The HTTP client underneath GemServer.api, for calls the generated surface does not cover.

PREFER api. It is typed against the platform's description, so a shape that changes becomes a build error in your bundle. This one takes a path and checks nothing — reach for it when you genuinely need something api has no method for, and treat that as worth telling us about.

GemMatchServer.api#

readonly api: ServerApi

Every platform operation your server can call. This is the one you want.

See ServerApi.

GemMatchServer.match#

readonly match: { end: null }

Ending the match.

end() is HOW A SERVER-AUTHORITATIVE GAME FINISHES. It tells the platform the match is over; the platform then tells the runtime, and your server winds down through its ordinary shutdown. You do not stop the process yourself.

It always means THIS match — the id is already known, so there is nothing to pass and no way to end someone else's.

GemMatchServer.match.end#

end(signal?: AbortSignal): Promise<void>

Tell the platform this match is finished.

ParameterTypeDescription
signal?AbortSignalCancels the call — not the match.

GemMatchServer.room#

readonly room?: { get: (options?: { ifNoneMatch?: string; signal?: AbortSignal }) => Promise<VersionedResponse<GameRoomResponseV2>>; args: (options?: { signal?: AbortSignal }) => Promise<RoomArgsResponse> }

Reading the room this match came from — the two calls almost every server makes at startup, already pointed at your own room.

CHECK IT IS THERE. room is absent on older platform images, which is why the type says it might be. gem.room?.get() is the shape to write. When it is absent the same reads are still available through api if you know the room id from somewhere else.

GemMatchServer.room.get#

get: (options?: { ifNoneMatch?: string; signal?: AbortSignal }) => Promise<VersionedResponse<GameRoomResponseV2>>

The room, in full: the settings it was created with, the template those came from, and every member — with display name, role, ready state and avatar.

This is what you want at start(), and it is a superset of what the older roster push carried.

GemMatchServer.room.args#

args: (options?: { signal?: AbortSignal }) => Promise<RoomArgsResponse>

Just the room's settings.

A room whose template declares no settings answers 404 rather than an empty object — "this room has no settings" and "this room's settings are empty" are different answers and the API keeps them apart.

GemMatchServer.dispose#

dispose(): void

Stop listening for credential updates.

You rarely need it: a bundle built with defineServer is disposed for you when the match ends. Call it in a test, or if you built the server yourself and are tearing it down.

GemMatchServer.sizeClass#

readonly sizeClass: string

Which size of machine this server was allocated on.

Useful if your game scales what it does to the hardware it got.

GemMatchServer.roomArgs#

readonly roomArgs: Readonly<Record<string, RoomArgValue>> | undefined

Deprecated. Use `gem.room?.args()` instead. It reads the same settings from the platform and can tell "this room type has no settings" apart from "it has settings and they are empty" — a difference this snapshot cannot express. This is still delivered and is not going away without notice.

The room's settings as they were at match start.

GemMatchServer.roster#

readonly roster: readonly string[]

Deprecated. Use `gem.room?.get()` instead: live membership, with names, roles and ready state. This is still delivered and is not going away without notice.

Who was in the room when the server was allocated.

A HINT FOR SIZING, NOT A GUEST LIST. It is never revised, and it is not what decides who may connect — the platform admits players by credential, not by this array.

GemMatchServer.players#

players(): string[]

The players connected right now, as a fresh array each call.

GemMatchServer.profile#

profile(playerId: string): GemServerProfile | null

A connected player's display identity, or null if the platform has none for them.

ParameterTypeDescription
playerIdstringTake this from join() or players(), never from something a client sent you.

GemMatchServer.playerNumber#

playerNumber(playerId: string): number | undefined

The seat number the platform gave a player — 1-based.

undefined when there is no answer: an id that is not seated, or an older platform image that does not assign seats. You cannot tell those apart and do not need to — handle the absence.

ParameterTypeDescription
playerIdstring

GemMatchServer.send#

send(playerId: string, data: string | Uint8Array<ArrayBufferLike>, options?: SendOptions): void

Send to one player.

ParameterTypeDescription
playerIdstringWho to send to.
datastring | Uint8Array<ArrayBufferLike>A string or bytes. The receiver is told which.
options?SendOptionsWhat delivery you are ASKING FOR — see SendOptions. Both default to true. Pass them freely: an older platform image ignores them harmlessly.

GemMatchServer.broadcast#

broadcast(data: string | Uint8Array<ArrayBufferLike>, options?: BroadcastOptions): void

Send to every connected player.

ParameterTypeDescription
datastring | Uint8Array<ArrayBufferLike>A string or bytes.
options?BroadcastOptionsSee BroadcastOptions — the same delivery choices as GemMatchServer.send, plus who to leave out.

GemMatchServer.kick#

kick(playerId: string, reason?: string): void

Disconnect a player.

ParameterTypeDescription
playerIdstringWho to remove.
reason?stringShown to them, and recorded in the match's logs.

GemMatchServer.log#

log(message: string): void

Write a line to the match's logs.

USE THIS, NEVER console.log. Standard output belongs to the platform — it is how the runtime talks to your server — and writing to it directly corrupts that conversation.

ParameterTypeDescription
messagestring

GemMatchServer.warn#

warn(message: string): void

Like GemMatchServer.log, marked as a warning.

ParameterTypeDescription
messagestring

GemServerBundle#

interface GemServerBundle

The five things that can happen to your server. Define the ones you care about; each may be async and is awaited before the next event arrives.

This is what your setup function returns — see defineServer. Events are delivered one at a time, in order, so you never handle two at once and do not need locking around your own state.

export default defineServer((gem) => {
  const scores = new Map();
  return {
    join(playerId) { scores.set(playerId, 0); },
    leave(playerId) { scores.delete(playerId); },
    message(playerId, data, isText) {
      if (isText) scores.set(playerId, scores.get(playerId) + Number(data));
    },
    async shutdown() { await gem.match.end(); },
  };
});

GemServerBundle.init#

init(): void | Promise<void>

Once, before any player arrives. Set your world up here.

YOU HAVE 60 SECONDS. Throwing fails the match, so do not start anything here you are not prepared to have fail loudly.

GemServerBundle.join#

join(playerId: string, meta?: { playerNumber?: number; transport?: string }): void | Promise<void>

A player connected, and the platform has already verified who they are.

They are ALREADY in host.players() by the time this runs.

ParameterTypeDescription
playerIdstringWho joined. Take ids from here rather than from anything a client sent you.
meta?{ playerNumber?: number; transport?: string }What the platform knows about the arrival — the seat it gave them, and which transport carried them. MAY BE ABSENT on older platform images, so default before use.

GemServerBundle.leave#

leave(playerId: string, meta?: { cause?: string }): void | Promise<void>

A player's connection closed. They are ALREADY gone from host.players().

ParameterTypeDescription
playerIdstringWho left.
meta?{ cause?: string }Why, when the platform knows. May be absent.

GemServerBundle.message#

message(playerId: string, data: string | Uint8Array<ArrayBufferLike>, isText: boolean, meta?: { reliable?: boolean; ordered?: boolean; sessionId?: number; playerNumber?: number }): void | Promise<void>

A player sent you something.

ParameterTypeDescription
playerIdstringWho sent it.
datastring | Uint8Array<ArrayBufferLike>The message: a string when isText, bytes otherwise.
isTextbooleanWhich of the two data is. CHECK IT rather than guessing — treating bytes as text corrupts them.
meta?{ reliable?: boolean; ordered?: boolean; sessionId?: number; playerNumber?: number }What the sender ASKED FOR — reliable and ordered — carried across whatever the transport actually did. May be absent.

GemServerBundle.shutdown#

shutdown(): void | Promise<void>

The match is ending, or the platform is reclaiming the machine. Save what just happened.

YOU HAVE LONGER THAN YOU THINK, and how much depends on which of the two it is. When the match ends: 10 minutes before the process goes — deliberately generous, because writing results is what servers do here. When the platform is reclaiming the machine: up to 2 hours, so a match with players still in it plays out rather than dying mid-game.

Guides. Writing the server for a dedicated match

Calling the platform

GemServer#

interface GemServer

Your server's connection to the platform — what createGemServer returns.

Build one in init(host), keep it, and call through it for the life of the match. It holds the credential and refreshes it for you; there is nothing to sign, renew or attach.

import { defineServer } from '@indiegems/gem-web-sdk/server';

export default defineServer((gem) => ({
  async init() {
    const room = await gem.room?.get();
    gem.log(`starting with ${room?.members.length ?? 0} player(s)`);
  },
  async shutdown() {
    await gem.match.end();
  },
}));

GemServer.identity#

readonly identity: GemServerIdentity

Which game, channel and match this server is running.

GemServer.client#

readonly client: GemClient

The HTTP client underneath GemServer.api, for calls the generated surface does not cover.

PREFER api. It is typed against the platform's description, so a shape that changes becomes a build error in your bundle. This one takes a path and checks nothing — reach for it when you genuinely need something api has no method for, and treat that as worth telling us about.

GemServer.api#

readonly api: ServerApi

Every platform operation your server can call. This is the one you want.

See ServerApi.

GemServer.match#

readonly match: { end: null }

Ending the match.

end() is HOW A SERVER-AUTHORITATIVE GAME FINISHES. It tells the platform the match is over; the platform then tells the runtime, and your server winds down through its ordinary shutdown. You do not stop the process yourself.

It always means THIS match — the id is already known, so there is nothing to pass and no way to end someone else's.

GemServer.match.end#

end(signal?: AbortSignal): Promise<void>

Tell the platform this match is finished.

ParameterTypeDescription
signal?AbortSignalCancels the call — not the match.

GemServer.room#

readonly room?: { get: (options?: { ifNoneMatch?: string; signal?: AbortSignal }) => Promise<VersionedResponse<GameRoomResponseV2>>; args: (options?: { signal?: AbortSignal }) => Promise<RoomArgsResponse> }

Reading the room this match came from — the two calls almost every server makes at startup, already pointed at your own room.

CHECK IT IS THERE. room is absent on older platform images, which is why the type says it might be. gem.room?.get() is the shape to write. When it is absent the same reads are still available through api if you know the room id from somewhere else.

GemServer.room.get#

get: (options?: { ifNoneMatch?: string; signal?: AbortSignal }) => Promise<VersionedResponse<GameRoomResponseV2>>

The room, in full: the settings it was created with, the template those came from, and every member — with display name, role, ready state and avatar.

This is what you want at start(), and it is a superset of what the older roster push carried.

GemServer.room.args#

args: (options?: { signal?: AbortSignal }) => Promise<RoomArgsResponse>

Just the room's settings.

A room whose template declares no settings answers 404 rather than an empty object — "this room has no settings" and "this room's settings are empty" are different answers and the API keeps them apart.

GemServer.dispose#

dispose(): void

Stop listening for credential updates.

You rarely need it: a bundle built with defineServer is disposed for you when the match ends. Call it in a test, or if you built the server yourself and are tearing it down.

GemServerIdentity#

interface GemServerIdentity

Which game, channel and match this server was started for.

You never construct one. The platform decides all of it when it allocates the server, and GemServer.identity is it restated in one place so you do not have to keep reaching back to the host object.

These are also the ids bound into every call api makes, which is why a bundle never types a game id and cannot name another game's.

GemServerIdentity.gameId#

readonly gameId: string

The game this server belongs to.

GemServerIdentity.channel#

readonly channel: string

The channel it was allocated on — dev, test or release.

GemServerIdentity.matchId#

readonly matchId: string

The match it is running.

GemServerIdentity.gameRoomId#

readonly gameRoomId?: string

The room this match came from, when there is one.

CHECK IT BEFORE USING IT. It is absent on older platform images, which is also why GemServer.room can be absent — the two go together.

GemServerOptions#

interface GemServerOptions

Options for createGemServer. Every one is optional, and a bundle running on the platform normally passes none.

They exist for the two cases the platform does not cover: pointing a bundle at something other than the API the platform named, and running it in a test.

GemServerOptions.baseUrl#

readonly baseUrl?: string

Where to send API calls, INSTEAD of where the platform said.

The host already names it, and a bundle running for real should not second-guess that. This is for a rig — a tunnel, an API running on your own machine — not for choosing which environment you are in. Setting it in a shipped bundle means your server talks to somewhere the platform did not send it.

GemServerOptions.clock#

readonly clock?: Clock

Where time comes from. Defaults to the real clock.

Pass a Clock in a test so token refresh and retry backoff happen when you say rather than when a real second passes.

GemServerOptions.fetch#

readonly fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>

The fetch used for every call. Defaults to the runtime's own.

For tests: a stub here is how you assert what your bundle sends without a network.

GemServerOptions.onAuthStateChange#

readonly onAuthStateChange?: (state: AuthState, reason?: AuthFailure) => void

Called whenever the server's ability to make authenticated calls changes.

The same signal the browser half delivers to a game, for the same reason: the token is refreshed for you, and this is how you find out when that stops working. See AuthState for what to branch on and AuthFailure for the cause.

GameApi#

interface GameApi

What a generated call needs in order to make itself: the client that sends it, and the game it belongs to.

YOU DO NOT BUILD ONE. createGemServer does, and hands you the operations already holding it as gem.api. It is named here because it appears in the type of those operations, so a helper of your own that takes them has to be able to spell it.

gameId is a call rather than a field because the game is not known until the platform has issued a credential, which happens after the client exists. It is never something a caller passes: a bundle cannot address another game's data because it never types a game id, in a path or in a query.

GameApi.client#

readonly client: GemClient

The client every operation sends through.

GameApi.gameId#

gameId(): string

The game this server is running — taken from its credential.

createGemServer#

function createGemServer(host: ServerBundleHost, options?: GemServerOptions): GemServer

Build your server's connection to the platform. Call it once, in init(host).

Pass the host object the runtime handed you and you get back a GemServer: the platform operations, the room reads for your own room, and a way to end the match. The credential is taken from the host and kept fresh for you.

MOST BUNDLES DO NOT CALL THIS. defineServer does it for you and hands you the result. Reach for it directly when you are managing the lifecycle yourself.

IT THROWS AT STARTUP if the runtime did not name an API to call — an old platform image. That is deliberate: a server that silently cannot reach the platform is worse than one that refuses to start.

ParameterTypeDescription
hostServerBundleHostThe object the runtime passes to init.
optionsGemServerOptionsSee GemServerOptions — normally omit it.

Throws. When the runtime named no API host, so no call could succeed.

import { createGemServer } from '@indiegems/gem-web-sdk/server';

export async function init(host) {
  const gem = createGemServer(host);
  const room = await gem.room?.get();
  host.log(`match ${gem.identity.matchId} with ${room?.members.length ?? 0}`);
}

Guides. Writing the server for a dedicated match

ServerApi#

type ServerApi = unknown

Every platform operation your server can call, already holding its credential.

This is the surface you spend most of your time in: gem.api.getGameRoomV2(id), gem.api.createOrder(...), and the rest. Each method's arguments and return type come from the platform's own API description, so what you can pass and what you get back are the contract rather than someone's reading of it.

game_id is never a parameter. It is filled in from the allocation — in the path and the query alike — so a bundle cannot address another game's data even by mistake.

The operation list moves with the platform. An operation added to your game's API appears here without this package changing, and one that is withdrawn stops type-checking rather than failing at runtime.

Guides. Writing the server for a dedicated match

The lifecycle

ServerBundleHost#

interface ServerBundleHost

The part of the host object that identifies the match and carries the credential.

This is what createGemServer needs, and nothing more — which is also what makes it easy to stand up in a test: four members instead of the whole host. GemServerHost is the same object at full width.

ServerBundleHost.matchId#

readonly matchId: string

The match this server was allocated for.

ServerBundleHost.gameId#

readonly gameId: string

The game it belongs to.

ServerBundleHost.channel#

readonly channel: string

The channel it was allocated on — dev, test or release.

ServerBundleHost.gameRoomId#

readonly gameRoomId?: string

The room this match came from. MAY BE ABSENT on older platform images, so check before using it — that absence is also why GemServer.room can be missing.

ServerBundleHost.apiBaseUrl#

readonly apiBaseUrl?: string

Where to send API calls. Absent on older platform images, which is the case createGemServer refuses at startup.

ServerBundleHost.serverToken#

serverToken(): ServerTokenGrant | undefined

The credential held right now, if the platform delivered one.

ServerBundleHost.onServerToken#

onServerToken(listener: (grant: ServerTokenGrant) => void): () => void

Be told when the credential is replaced.

Called straight away if one is already held, and again on every refresh. Returns a function that stops the notifications.

You rarely call this. createGemServer subscribes for you.

ParameterTypeDescription
listener(grant: ServerTokenGrant) => void

GemServerLifecycle#

interface GemServerLifecycle

What the platform actually calls — a GemServerBundle after the SDK has wrapped it.

YOU DO NOT WRITE ONE. defineServer produces it from your bundle. It is named here so you can type a variable holding the result, and so the shape the platform drives is written down somewhere a reader can find it.

init and shutdown are required here although both are optional on your bundle: the SDK always defines them, to receive the host and to release the credential subscription when the match is over.

GemServerLifecycle.init#

init(host: GemServerHost): Promise<void>

Called first, handing over the host object. This is where the SDK takes it and where your own init runs.

ParameterTypeDescription
hostGemServerHost

GemServerLifecycle.join#

join(playerId: string, meta?: { playerNumber?: number; transport?: string }): void | Promise<void>

Forwarded from your bundle, when you defined a join.

ParameterTypeDescription
playerIdstring
meta?{ playerNumber?: number; transport?: string }

GemServerLifecycle.leave#

leave(playerId: string, meta?: { cause?: string }): void | Promise<void>

Forwarded from your bundle, when you defined a leave.

ParameterTypeDescription
playerIdstring
meta?{ cause?: string }

GemServerLifecycle.message#

message(playerId: string, data: string | Uint8Array<ArrayBufferLike>, isText: boolean, meta?: { reliable?: boolean; ordered?: boolean; sessionId?: number; playerNumber?: number }): void | Promise<void>

Forwarded from your bundle, when you defined a message.

ParameterTypeDescription
playerIdstring
datastring | Uint8Array<ArrayBufferLike>
isTextboolean
meta?{ reliable?: boolean; ordered?: boolean; sessionId?: number; playerNumber?: number }

GemServerLifecycle.shutdown#

shutdown(): Promise<void>

Always defined, even if your bundle has no shutdown — the SDK has teardown of its own to do, and it runs whether or not yours threw.

GemServerFactory#

type GemServerFactory = () => GemServerLifecycle

The type of your bundle's default export — what defineServer returns.

The platform calls it once, at the start of a match.

Serving a match

GemServerProfile#

interface GemServerProfile

Who a player is, for display. Safe to hold; think before you relay it.

Returned by GemMatchServer.profile.

RELAY THE ID, NOT THE NAME. Sending player ids to your clients and letting each one render the name it already has is better than broadcasting names through your server: it is less to send, and it keeps one player's text from reaching every other player's screen through you.

GemServerProfile.displayName#

readonly displayName: PlayerText

The name the player chose — so it is PlayerText, not a plain string.

The type is the reminder: this is text a stranger wrote. text.toPlain is the one way to turn it into a string, and on a server the honest answer is usually that you never need to.

GemServerProfile.displayTag#

readonly displayTag: string

The platform-assigned tag beside the name. Not player-authored, so it is an ordinary string.

GemServerProfile.discriminator#

readonly discriminator?: string

The disambiguating suffix, when the player has one.

BroadcastOptions#

interface BroadcastOptions

The same delivery choices as SendOptions, plus who to skip.

BroadcastOptions.reliable#

readonly reliable?: boolean

Ask for the message to arrive. Defaults to true.

false says you would rather it be dropped than delayed — position updates that a later one supersedes. A transport is free to deliver it reliably anyway; the receiver is still told what you asked for.

BroadcastOptions.ordered#

readonly ordered?: boolean

Ask for messages to arrive in the order you sent them. Defaults to true.

BroadcastOptions.except#

readonly except?: string

A player to leave out — usually the one whose action caused the broadcast.

SendOptions#

interface SendOptions

What delivery you are ASKING FOR when you send.

A request, not a guarantee in either direction: a transport may deliver an unreliable message reliably anyway, and it never does the reverse. What you asked for is carried to the receiver either way, so they can tell the difference.

SendOptions.reliable#

readonly reliable?: boolean

Ask for the message to arrive. Defaults to true.

false says you would rather it be dropped than delayed — position updates that a later one supersedes. A transport is free to deliver it reliably anyway; the receiver is still told what you asked for.

SendOptions.ordered#

readonly ordered?: boolean

Ask for messages to arrive in the order you sent them. Defaults to true.

createMatchServer#

function createMatchServer(host: GemServerHost, options?: GemServerOptions): GemMatchServer

Build the gem object yourself, when you are driving the lifecycle by hand.

MOST BUNDLES WANT defineServer, which calls this for you and wires up your handlers. Use this one if you are writing the lifecycle object directly.

CALL IT FROM init, not at module scope — it needs the host, and it builds the platform connection immediately, which is what turns "this platform image is too old" into a startup failure rather than a surprise mid-match. Call dispose() from shutdown.

ParameterTypeDescription
hostGemServerHostThe object the runtime passed to init.
optionsGemServerOptionsPassed through to createGemServer. Normally omit it.

Throws. When the runtime named no API host, so no call could succeed.

import { createMatchServer } from '@indiegems/gem-web-sdk/server';

export default () => {
  let gem;
  return {
    async init(host) { gem = createMatchServer(host); },
    join(playerId) { gem.broadcast(`${playerId} joined`); },
    async shutdown() { await gem.match.end(); gem.dispose(); },
  };
};

The host object

GemServerHost#

interface GemServerHost

The whole host object, as the runtime passes it to your bundle.

ServerBundleHost is its credential half; everything added here is the match — who is connected, what they sent, and how to answer.

SEVERAL MEMBERS ARE OPTIONAL BECAUSE THE PLATFORM IMAGE MAY BE OLDER than the member. There is no version to ask for; presence is the signal. Check before calling, and your bundle keeps running on every image rather than only the newest.

A bundle written with defineServer usually reads GemMatchServer instead, which wraps this.

GemServerHost.matchId#

readonly matchId: string

The match this server was allocated for.

GemServerHost.gameId#

readonly gameId: string

The game it belongs to.

GemServerHost.channel#

readonly channel: string

The channel it was allocated on — dev, test or release.

GemServerHost.gameRoomId#

readonly gameRoomId?: string

The room this match came from. MAY BE ABSENT on older platform images, so check before using it — that absence is also why GemServer.room can be missing.

GemServerHost.apiBaseUrl#

readonly apiBaseUrl?: string

Where to send API calls. Absent on older platform images, which is the case createGemServer refuses at startup.

GemServerHost.serverToken#

serverToken(): ServerTokenGrant | undefined

The credential held right now, if the platform delivered one.

GemServerHost.onServerToken#

onServerToken(listener: (grant: ServerTokenGrant) => void): () => void

Be told when the credential is replaced.

Called straight away if one is already held, and again on every refresh. Returns a function that stops the notifications.

You rarely call this. createGemServer subscribes for you.

ParameterTypeDescription
listener(grant: ServerTokenGrant) => void

GemServerHost.sizeClass#

readonly sizeClass: string

Which size of machine this server was allocated on.

GemServerHost.roomArgs#

readonly roomArgs?: Readonly<Record<string, RoomArgValue>>

Deprecated. Read `gem.room?.args()` instead — it gets the same settings from the platform and distinguishes "no settings are declared" from "they are declared and empty". Still delivered; not going away without notice.

The room's settings as they were at match start.

undefined covers two cases you cannot tell apart and do not need to: the room type declares no settings, or the platform image is older than this member.

GemServerHost.roster#

readonly roster: readonly string[]

Deprecated. Read `gem.room?.get()` instead — live membership, with names, roles and ready state. Still delivered; not going away without notice.

Who was in the room when this server was allocated.

A SIZING HINT, NOT A GUEST LIST. It is never revised, and it does not decide who may connect — the platform admits players by credential. A player who joined after allocation holds a valid one and is not in here.

GemServerHost.players#

players(): string[]

The player ids connected right now, as a fresh array each call.

GemServerHost.profile#

profile(playerId: string): GemServerHostProfile | undefined

A player's display identity, if the platform resolved one.

OPTIONAL TWICE OVER: the method itself may be absent on an older platform image, and it may answer undefined for a player it has nothing for. GemMatchServer.profile collapses both into one null.

ParameterTypeDescription
playerIdstring

GemServerHost.playerNumber#

playerNumber(playerId: string): number | undefined

The 1-based seat the platform gave a connected player.

May be absent as a method on an older platform image, and answers undefined for an id it has not seated.

ParameterTypeDescription
playerIdstring

GemServerHost.send#

send(playerId: string, data: string | Uint8Array<ArrayBufferLike>, options?: SendOptions): void

Send to one player. A string goes as text; bytes go as binary, and the receiver is told which.

ParameterTypeDescription
playerIdstring
datastring | Uint8Array<ArrayBufferLike>
options?SendOptionsWhat delivery you are asking for — see SendOptions. Pass them freely: an older platform image ignores them harmlessly.

GemServerHost.broadcast#

broadcast(data: string | Uint8Array<ArrayBufferLike>, options?: BroadcastOptions): void

Send to every connected player. See BroadcastOptions for how, and for leaving one out.

ParameterTypeDescription
datastring | Uint8Array<ArrayBufferLike>
options?BroadcastOptions

GemServerHost.kick#

kick(playerId: string, reason?: string): void

Disconnect a player. reason is shown to them and recorded in the logs.

ParameterTypeDescription
playerIdstring
reason?string

GemServerHost.log#

log(message: string): void

Write a line to the match's logs.

THE ONLY ROUTE. console.log writes to standard output, which is how the runtime talks to your server — a line written there arrives as a complaint about your bundle rather than as a log.

ParameterTypeDescription
messagestring

GemServerHost.warn#

warn(message: string): void

Like GemServerHost.log, marked as a warning.

ParameterTypeDescription
messagestring

TokenGrant#

interface TokenGrant

A credential and the two deadlines on it.

YOU DO NOT HANDLE ONE IN NORMAL USE. The SDK obtains it, refreshes it before it expires, and attaches it to every call. It is named here because the token-source signatures a bundle reads when wiring things up itself mention it.

TWO DEADLINES, AND THEY MEAN DIFFERENT THINGS. expiresAt is when this credential stops working and a fresh one is fetched — routine, invisible. chainEndsAt is when fetching a fresh one stops being possible at all: past it the session is over and only signing in again helps.

TokenGrant.token#

readonly token: string

The credential itself.

NEVER LOG IT AND NEVER FORWARD IT. Anything holding it can act as this session until it expires.

TokenGrant.expiresAt#

readonly expiresAt: number

When this credential stops working, in seconds since the Unix epoch.

Routine — the SDK renews before this, and you do not see it happen.

TokenGrant.chainEndsAt#

readonly chainEndsAt: number

When renewal stops being possible, in seconds since the Unix epoch.

Past this the session cannot be extended by anything. Watch AuthState rather than this number.

GemServerHostProfile#

interface GemServerHostProfile

A player's display identity, as the platform resolved it for your server.

EVERY FIELD IS OPTIONAL. The platform may not have all of them for a given player, and that is not an error — fall back to something of your own.

GemMatchServer.profile is the version you usually want: same information, with the name typed as PlayerText.

GemServerHostProfile.displayName#

readonly displayName?: string

The player's chosen name, as a plain string.

PREFER GemMatchServer.profile, which gives you the same name as PlayerText — a type that reminds you a stranger wrote it.

GemServerHostProfile.displayTag#

readonly displayTag?: string

The platform-assigned tag beside the name.

GemServerHostProfile.discriminator#

readonly discriminator?: string

The disambiguating suffix, when the player has one.

HostTokenSourceOptions#

interface HostTokenSourceOptions

Options for createHostTokenSource.

You need these only if you are wiring the credential up yourself. createGemServer does it for you.

HostTokenSourceOptions.clock#

readonly clock: Clock

Where time comes from — used for expiry and for the wait below. Pass a Clock in a test.

HostTokenSourceOptions.timeoutMs#

readonly timeoutMs?: number

How long to wait for the platform to deliver a credential before giving up on a call. Defaults to 10 seconds.

HostTokenSourceOptions.onStateChange#

readonly onStateChange?: (state: AuthState, reason?: AuthFailure) => void

Called when the ability to make authenticated calls changes. See AuthState for what to branch on.

ServerTokenGrant#

interface ServerTokenGrant

A credential the platform handed your server, and when it stops working.

You do not normally touch one. createGemServer takes the credential from the host and refreshes it for you; this type is here for a bundle wiring the pieces up itself.

ServerTokenGrant.token#

readonly token: string

The credential to present. Never log it and never send it on.

ServerTokenGrant.expiresAt#

readonly expiresAt: number

When it expires, in seconds since the Unix epoch.

HostTokenSource#

interface HostTokenSource

A credential subscription — what createHostTokenSource gives back.

Deliberately narrow: the only thing you can do with it is stop it. The credential itself is not reachable, because nothing you can call takes one.

HostTokenSource.dispose#

dispose(): void

Stop refreshing the credential.

CALL IT AT SHUTDOWN. A pending refresh timer keeps the process alive, so a bundle that skips this looks like it has hung rather than exited.

createHostTokenSource#

function createHostTokenSource(host: ServerBundleHost, options: HostTokenSourceOptions): HostTokenSource

Keep the platform credential fresh, without building the rest of the SDK.

MOST BUNDLES NEVER CALL THIS. createGemServer does it, and hands you a client that is already authenticated. Reach for it when you are assembling the pieces yourself.

It subscribes to the host's credential pushes straight away. Call dispose() when you are done, or a pending refresh timer will keep your process alive after the match is over.

ParameterTypeDescription
hostServerBundleHostThe object the runtime passed to init.
optionsHostTokenSourceOptionsSee HostTokenSourceOptionsclock is required.

Returns. Something you can stop. See HostTokenSource.

RoomArgValue#

type RoomArgValue = string | number | boolean | readonly string[] | readonly number[] | readonly boolean[]

What a room setting can be: a string, a number, a boolean, or an array of one of those.

FLAT. There is no nested-object arm, because a room's settings schema cannot declare one. What you get is what your game's published configuration said a room may be created with, already type-checked by the platform before the match was allocated — so a bundle reading settings does not need to validate them again.

Guides. Rooms and the match lifecycle

Making calls

GemClient#

class GemClient

The HTTP client underneath the generated operations — for calls those do not cover.

PREFER ServerApi. Its methods are typed against the platform's own description, so a shape that changes becomes a build error in your bundle. This one takes a path and checks nothing you give it.

What it does give you is everything around the call: the credential, retries on the requests that are safe to retry, and failures already classified into GemApiError so you can branch on a reason instead of a status.

ONLY READS ARE RETRIED. A write that timed out may well have been applied, and retrying it silently is how a client duplicates a purchase it cannot see. Whether your write is safe to repeat is something only you know.

You reach one as gem.client; there is normally no reason to construct it.

GemClient.constructor#

constructor(options: ClientOptions): GemClient

Build a client. You normally do not — createGemServer builds one and hands it to you as gem.client.

ParameterTypeDescription
optionsClientOptionsSee ClientOptions.

GemClient.get#

get<T>(path: string, signal?: AbortSignal): Promise<T>

Read something.

ParameterTypeDescription
pathstringFrom the API root, e.g. /v1/players/me/settings.
signal?AbortSignalCancels the read.

Throws. GemApiError on a refusal — branch on reason.

GemClient.getWithEtag#

getWithEtag<T>(path: string, options?: VersionedGetOptions): Promise<VersionedResponse<T>>

Read something AND get its version back — or find out it has not changed.

The read half of optimistic concurrency: keep the version this returns, pass it as ifMatch on your write, and the write is refused rather than overwriting somebody. Pass it as ifNoneMatch here and an unchanged resource comes back with no body at all.

SEPARATE FROM GemClient.get on purpose — get answers with the value and nothing else, which is what most calls want.

ParameterTypeDescription
pathstringFrom the API root.
optionsVersionedGetOptionsSee VersionedGetOptions.

Returns. See VersionedResponse — check modified before reading a body.

Throws. GemApiError on a refusal.

GemClient.put#

put<T>(path: string, body: unknown, signal?: AbortSignal, options?: RequestOptions): Promise<T>

Replace something.

NOT RETRIED. A write that timed out may already have been applied.

ParameterTypeDescription
pathstringFrom the API root.
bodyunknownWhat to send. Encoded as JSON.
signal?AbortSignal
optionsRequestOptionsSee RequestOptionsifMatch is how you avoid overwriting someone else's write.

Throws. GemApiError on a refusal.

GemClient.post#

post<T>(path: string, body: unknown, signal?: AbortSignal, options?: RequestOptions): Promise<T>

Create something, or perform an action.

NOT RETRIED, for the same reason as GemClient.put.

ParameterTypeDescription
pathstringFrom the API root.
bodyunknownWhat to send. Encoded as JSON.
signal?AbortSignal
optionsRequestOptionsSee RequestOptions.

Throws. GemApiError on a refusal.

GemClient.patch#

patch<T>(path: string, body: unknown, signal?: AbortSignal, options?: RequestOptions): Promise<T>

Change part of something.

NOT RETRIED, for the same reason as GemClient.put.

ParameterTypeDescription
pathstringFrom the API root.
bodyunknownThe change. Encoded as JSON.
signal?AbortSignal
optionsRequestOptionsSee RequestOptions.

Throws. GemApiError on a refusal.

GemClient.delete#

delete<T>(path: string, signal?: AbortSignal, options?: RequestOptions): Promise<T>

Remove something.

NOT RETRIED, for the same reason as GemClient.put.

ParameterTypeDescription
pathstringFrom the API root.
signal?AbortSignalCancels the call.
optionsRequestOptionsSee RequestOptions.

Throws. GemApiError on a refusal.

TransportRequest#

interface TransportRequest

One outgoing call, as a Transport receives it.

You meet this when you write a transport — usually in a test, to assert what your code sends without a network.

TransportRequest.method#

readonly method: "GET" | "PUT" | "POST" | "PATCH" | "DELETE"

The HTTP method.

TransportRequest.path#

readonly path: string

The path, from the API root — /v1/players/me/settings. Never a full URL: where it is sent is not a call's decision.

TransportRequest.body#

readonly body?: unknown

The request body, if there is one. Encoded as JSON.

TransportRequest.headers#

readonly headers?: Partial<Record<"if-match" | "if-none-match", string>>

See ConditionalRequestHeaders — nothing else may be set.

TransportRequest.signal#

readonly signal?: AbortSignal

Cancels the request. Timeouts are the caller's policy, not the SDK's.

TransportRequest.keepalive#

readonly keepalive?: boolean

Let the request outlive the page.

For the write fired as a player leaves. Without this, a request started while the page is unloading is simply cancelled — it looks like it was sent, and nothing arrives.

NOT ON EVERYTHING, because browsers cap how much can be in flight this way (64 KB across all such requests). Use it for the write that matters.

TransportResponse#

interface TransportResponse

What a Transport answers with.

TWO HEADERS ARE PARSED OUT and there is no header bag, because these are the only two anything above this point reads — which also keeps a stub in a test to a couple of fields.

TransportResponse.status#

readonly status: number

The HTTP status.

TransportResponse.body#

readonly body: unknown

The decoded response body.

TransportResponse.etag#

readonly etag?: string

The resource's version, parsed out of the response.

Absent when the server sent none — not every resource is versioned. If you are writing a transport for a test, set it: leaving it out makes every conditional read look unversioned.

TransportResponse.retryAfterSeconds#

readonly retryAfterSeconds?: number

How long the server asked you to wait, in WHOLE SECONDS.

Seconds, not milliseconds — do not divide it by anything.

ABSENT MEANS "NO ESTIMATE", NEVER "DO NOT RETRY". Whether a refusal is worth repeating is GemApiError.retryable's answer, not this field's.

ClientOptions#

interface ClientOptions

How a GemClient is built.

You rarely construct one yourself — createGemServer does it — but the type has to be nameable to write a helper that takes one.

ClientOptions.transport#

readonly transport: Transport

How requests actually leave. Substitute one to test without a network — see Transport.

ClientOptions.clock#

readonly clock: Clock

Where time comes from, for retry backoff. See Clock.

ClientOptions.maxRetries#

readonly maxRetries?: number

How many times to retry a read that failed. Defaults to 2 — three attempts in all.

ONLY READS ARE RETRIED, whatever you set. A write that timed out may already have been applied, and repeating it is how a client charges someone twice. Whether your write is safe to repeat is something only you know, so the SDK does not guess.

RequestOptions#

interface RequestOptions

Per-call options, for the occasional call that needs one.

RequestOptions.keepalive#

readonly keepalive?: boolean

Let the request finish even if the page goes away.

For the last write before a player closes the tab. Browsers cap how much can be in flight this way, so use it for the write that matters, not for every write.

RequestOptions.ifMatch#

readonly ifMatch?: string

Only apply this write if nothing has changed since you read.

Pass the version that came back with the read your write is based on. If someone else wrote in between, the call fails with precondition_failed instead of overwriting them — re-read, decide again against the new state, and write again.

VersionedGetOptions#

interface VersionedGetOptions

Options for a read that also wants the value's version back.

VersionedGetOptions.signal#

readonly signal?: AbortSignal

Cancel the read. An aborted call rejects rather than resolving.

VersionedGetOptions.ifNoneMatch#

readonly ifNoneMatch?: string

Ask "has this changed?" instead of "give me this".

Pass the version from your last read. If nothing has changed you get back { modified: false } and no body — cheaper for you and for the platform.

Transport#

interface Transport

How calls actually leave — the seam you replace to test without a network.

The SDK builds a real one for you. Pass your own when you want to assert what your code sends, or to answer it without a server.

const sent = [];
const transport = {
  async request(req) {
    sent.push(req);
    return { status: 200, body: { ok: true } };
  },
};

Transport.request#

request(request: TransportRequest): Promise<TransportResponse>

Send one request and resolve with what came back.

ParameterTypeDescription
requestTransportRequest

VersionedResponse#

type VersionedResponse<T> = { modified: true; body: T; etag?: string } | { modified: false; etag?: string }

What a versioned read answers: either the value and its version, or "nothing changed".

CHECK modified FIRST. There is no body on the unchanged branch — not an empty one, none — so the type will not let you read it until you have asked. That is deliberate: an optional body would let an unhandled 304 through to production as undefined.

const result = await gem.client.getWithEtag(path, { ifNoneMatch: lastEtag });
if (result.modified) {
  render(result.body);
  lastEtag = result.etag;
}

CONDITIONAL_HEADERS#

CONDITIONAL_HEADERS: readonly ["if-match", "if-none-match"]

The only request headers a call may set: if-match and if-none-match.

A CLOSED SET, ON PURPOSE. Everything else — the credential, the content type, the client's identity — is the SDK's to send, and a call that could add its own headers could send headers the platform trusts.

ConditionalRequestHeaders#

type ConditionalRequestHeaders = Partial<Record<unknown[number], string>>

The type of TransportRequest.headers — derived from CONDITIONAL_HEADERS, so the two cannot disagree.

Values that need care

text#

text: { toPlain: null; compare: null; equals: null }

The only ways out of PlayerText.

Reached from the SDK global rather than from gem: const { text } = window.GemWebSdkGame;

Two calls:

  • toPlain(value) — the plain string, safe for textContent, canvas fillText and interpolation. The one call you make, and it belongs at your model boundary rather than scattered through render code.

  • compare(a, b, locale?) — locale-aware comparison for sorting rosters and leaderboards. Use it instead of converting and comparing with <, which sorts by code point and gets accented and non-Latin names wrong. It accepts plain strings too, because real leaderboards mix player names with rows your game authored, and it falls back rather than throwing on a bad locale tag — a throw inside a comparator takes the whole sort down.

const { text } = window.GemWebSdkGame;

const name = text.toPlain(profile.displayName);
players.sort((a, b) => text.compare(a.displayName, b.displayName, locale));

THERE IS DELIBERATELY NO toHtml. Escaping depends on where the value lands — safe in element text and quoted attributes, unsafe in URL, style and script contexts — and your framework already escapes. One unambiguous function beats two where one carries a caveat.

text.toPlain#

toPlain(t: PlayerText): string

The plain string, safe for textContent, canvas fillText and interpolation.

The one call you make, and it belongs at your model boundary rather than scattered through your render code:

const name = text.toPlain(profile.displayName);

Safe for text; NOT pre-escaped for HTML. Do not put the result into innerHTML, a URL, a style or a script — convert at the boundary and let your framework escape, which is what it already does.

ParameterTypeDescription
tPlayerText

text.compare#

compare(a: string | PlayerText, b: string | PlayerText, locale?: string): number

Locale-aware comparison, for sorting rosters and leaderboards.

Use this rather than converting to strings and comparing with <, which sorts by code point and gets accented and non-Latin names wrong.

players.sort((a, b) => text.compare(a.displayName, b.displayName, locale));

Accepts plain strings as well as player text, because real leaderboards mix player names with rows your game authored. Pass the player's locale from GemPrefs.locale; without one it uses the runtime's. A malformed locale tag falls back rather than throwing — this is a comparator, and a throw here would take the whole sort down with it.

ParameterTypeDescription
astring | PlayerText
bstring | PlayerText
locale?string

text.equals#

equals(a: string | PlayerText, b: string | PlayerText): boolean

Whether two names are the same name, without unwrapping either.

Compares normalised forms, so two spellings of the same name — one composed, one decomposed — come out equal. That matters because a name may reach your game from somewhere other than the arcade.

KEY YOUR ROWS ON THE PLAYER'S ID, NEVER ON A NAME. Names are not identifiers: two players can choose the same one, and a player can change theirs. Use this for display-level comparisons, not for identity.

ParameterTypeDescription
astring | PlayerText
bstring | PlayerText

PlayerText#

class PlayerText

A player-authored string, which your game must convert before rendering.

Player names are chosen by players, and length limits are not content limits. If your game assigned one straight to innerHTML, whoever picked that name would be running code on your page — with your play token, which is the whole session. So the SDK does not hand you a string: it hands you this, and the unsafe thing does not compile.

There is exactly one way out, and it belongs at your model boundary:

const { text } = window.GemWebSdkGame;

const name = text.toPlain(profile.displayName);
label.textContent = name;

To sort names, use text.compare rather than converting first — it is locale-aware, which a plain < comparison is not.

PlayerText.toString#

toString(): string

A sentinel that names its own fix, never the player's text.

If you see [gem: use gem.text.toPlain()] on screen, something rendered the wrapper instead of converting it. That is the gate working — the alternative was shipping an unescaped player name.

⚠️ THE SENTINEL NAMES gem.text, WHICH DOES NOT EXIST. text is reached from the SDK global — const { text } = window.GemWebSdkGame; — not from your gem instance. Follow that rather than the message.

PlayerText.toJSON#

toJSON(): string

The same sentinel, so putting a player name into a save blob or a telemetry payload cannot silently store unescaped text.

Convert with text.toPlain before serialising anything you intend to keep.

Base64String#

type Base64String = string & { __contentEncoding: "base64" }

A base64-encoded value — a string, but not one you can substitute an ordinary string for.

Some platform fields carry BYTES inside a JSON string, encoded as base64. An entity setting's value is the one you are most likely to meet. Passing ordinary text there fails the whole request, and it fails at runtime, on the server, after you shipped.

This type moves that failure to your build. { value: JSON.stringify(state) } type-checks perfectly against string and is exactly the mistake being stopped.

MAKE ONE WITH toBase64. Writing as Base64String instead silences the error and puts the trap back.

toBase64#

function toBase64(value: string | Uint8Array<ArrayBufferLike>): Base64String

Encode text or bytes for a field that wants base64.

The only way to produce a Base64String, and the reason not to reach for btoa yourself: btoa takes a binary string and throws on any character above U+00FF, so btoa(name) works all through your testing and then fails on the first player whose display name is not Latin-1. This encodes a string as UTF-8 first.

ParameterTypeDescription
valuestring | Uint8Array<ArrayBufferLike>Text, or bytes you already have.

Returns. The encoded value, typed so the field will accept it.

await gem.api.putEntityGameSetting('loadout', {
  value: toBase64(JSON.stringify(loadout)),
});

When something fails

GemApiError#

class GemApiError

A refusal from the Gem Arcade backend. Any call that reaches the platform can throw one.

You catch this; you never construct it. Branch on GemApiError.reason for what went wrong and GemApiError.retryable for whether another attempt could help — not on GemApiError.status, because one status covers several distinct refusals.

try {
  await somePlatformCall();
} catch (err) {
  if (err instanceof GemApiError && err.retryable) {
    const wait = err.retryAfterSeconds ?? 1;
    // try again after `wait` seconds
  } else {
    throw err;
  }
}

GemApiError.reason#

readonly reason: GemErrorReason

What went wrong, in terms you can act on — invalid, forbidden, not_found, conflict, precondition_failed, not_modified, insufficient_funds, too_large, rate_limited or unavailable.

This is the field to branch on. Two refusals can share a status and mean different things; they do not share a reason.

GemApiError.status#

readonly status: number

The HTTP status the server answered with.

Carried for logs and bug reports. Prefer GemApiError.reason for decisions: several distinct refusals map onto one status, so branching here treats different problems as the same one.

GemApiError.detail#

readonly detail: string

The server's own sentence, without the reason (status): prefix that message carries.

Use this when you want to quote the platform — in a log line, or a bug report. It is not written for players, so do not put it on screen.

GemApiError.retryAfterSeconds#

readonly retryAfterSeconds?: number

How long the server asked you to wait, in whole seconds.

ABSENT MEANS "NO ESTIMATE GIVEN", NEVER "DO NOT RETRY" — GemApiError.retryable is what answers that. A refusal can be worth repeating and still name no wait, so treat absence as "pick your own backoff" rather than as a stop signal. The platform estimates a wait for capacity and for rate limits, and sends none for the refusals it cannot put a number on.

GemApiError.code#

readonly code?: string

The platform's own error code, when it sent one — too_many_concurrent_matches, cluster_target_required, no_compatible_server.

Finer-grained than GemApiError.reason, and the only way to tell apart two refusals that share both a reason and a status. Absent on most refusals, so treat it as extra information rather than something to depend on.

GemApiError.retryable#

get retryable(): boolean

Whether another attempt could plausibly succeed.

True for unavailable and rate_limited and false for everything else — a forbidden or not_found will refuse identically however many times you send it. Check this before retrying, and wait GemApiError.retryAfterSeconds if the server named one.

AuthError#

class AuthError

The session cannot produce a usable token.

Thrown by any call that needs the player's session. You catch this; you never construct it.

IT CARRIES NO retryable FIELD, and that is deliberate rather than an oversight — AuthError.reason is the cause, and the retry verdict lives on the session's state. Watch GemOptions.onAuthStateChange and treat AuthState degraded as worth retrying and lost as terminal. A game that decides from the reason will retry a session that can never come back.

AuthError.reason#

readonly reason: AuthFailure

Why the token could not be produced. See AuthFailure — it is the cause, and not a signal about whether to try again.

AuthError.isAuthError#

isAuthError(error: unknown): error is AuthError

Whether a caught value is an AuthError.

Prefer this to instanceof. Two copies of the SDK can coexist on a page — the arcade vendors one build and games install another — and an error from one copy fails instanceof in the other. This check does not care which copy minted it.

try {
  await somePlatformCall();
} catch (err) {
  if (AuthError.isAuthError(err)) {
    // the session is the problem; watch onAuthStateChange for whether it recovers
  }
}

The call is left unnamed on purpose: this error reaches a game and a server bundle alike, and the two have no call in common.

ParameterTypeDescription
errorunknown

TransportRefusal#

class TransportRefusal

The request was never sent, because sending it would have been wrong.

ALWAYS YOUR BUG, never a transient fault, and never worth retrying — the same call will be refused identically every time. Six things produce one: a path or base URL that will not parse, a path that changes the protocol or host, a path or base URL carrying credentials, a body that will not serialise to JSON, a conditional-header value the wire cannot carry, and a retry count outside 0..10.

Nothing reached the platform, so there is no status and no response to read. Fix the call.

TransportRefusal.retryable#

readonly retryable: false

Always false.

Present so that code branching on retryable across the errors this SDK throws reads a real false here rather than undefined. A refusal is deterministic; retrying cannot change it.

TransportRefusal.isRefusal#

isRefusal(error: unknown): error is TransportRefusal

Whether a caught value is a TransportRefusal.

Prefer this to instanceof. Two copies of the SDK can coexist on a page — the arcade vendors one build and games install another — and an error from one copy fails instanceof in the other.

ParameterTypeDescription
errorunknown

GemErrorReason#

type GemErrorReason = "invalid" | "forbidden" | "not_found" | "conflict" | "precondition_failed" | "not_modified" | "insufficient_funds" | "too_large" | "rate_limited" | "unavailable"

What went wrong, in terms you can act on.

BRANCH ON THIS, not on the HTTP status — one status covers several distinct refusals, and the reason is what tells them apart. It is carried by GemApiError.reason.

AuthFailure#

type AuthFailure = "chain_exhausted" | "unavailable" | "timeout" | "internal"

Why the session could not produce a usable token.

THIS IS THE CAUSE, NOT A VERDICT. It deliberately never distinguishes a sign-out from a ban from a revocation, and it does not tell you whether to try again — AuthState does that. A game that branches on the reason is guessing.

  • chain_exhausted — the token chain cannot be renewed any further.

  • unavailable — no token right now.

  • timeout — the arcade did not answer in time.

  • internal — something went wrong on this side.

AuthState#

type AuthState = "ready" | "pending" | "degraded" | "lost"

What the session can currently do, delivered to GemOptions.onAuthStateChange.

THIS IS THE ONE TO BRANCH ON, and the only route to it: the token source itself is internal, so a game cannot read this state by asking for it. Watch the callback instead.

  • ready — a usable token is held.

  • pending — no token yet, or one is being fetched.

  • degraded — the last attempt failed but another may succeed. Keep playing; a retry is worthwhile.

  • lost — terminal for this session. Signing back in is the only way forward, and retrying will not produce one.

Testing a bundle

Clock#

interface Clock

Where time comes from — so a test does not have to wait for it.

Credential refresh, retry backoff and timeouts are all wall-clock behaviour. A test that has to wait ten real seconds to prove a timeout fires is a test nobody runs, so every SDK option that involves time takes one of these.

Pass systemClock — or nothing, which means the same — to use real time. Pass your own to control it.

let now = 0;
const timers = [];
const clock = {
  now: () => now,
  setTimeout: (fn, ms) => timers.push({ fn, at: now + ms }),
  clearTimeout: () => {},
};
// advance time yourself, and run whatever is due

Clock.now#

now(): number

The current time, in milliseconds — the same scale as Date.now().

Clock.setTimeout#

setTimeout(fn: () => void, ms: number): unknown

Run fn after ms milliseconds. Return whatever identifies it; the SDK only ever hands that value back to Clock.clearTimeout.

ParameterTypeDescription
fn() => void
msnumber

Clock.clearTimeout#

clearTimeout(handle: unknown): void

Cancel a timer created by Clock.setTimeout.

ParameterTypeDescription
handleunknown

systemClock#

systemClock: Clock

Real time — the Clock every SDK option defaults to.

Name it when you want to be explicit, or wrap it when you want to watch what the SDK schedules without replacing the clock entirely.

Read this reference as Markdown