gameon-sdk
v2.1.2
Published
One SDK for every GameOn game: GameClient (game frontends) and GameOnPlatform (game servers). Contracts: specs/gameon.yaml, specs/platform.yaml
Maintainers
Readme
gameon-sdk
One SDK for every GameOn game. Every game backend serves the same endpoints and socket events, and talks to the GameOn platform the same way, so each game only writes its own game logic.
| Import | Where it runs | What it gives you |
| --- | --- | --- |
| gameon-sdk | browser and Node | GameClient (REST), GameConnection (realtime), GameOnClient, shared types |
| gameon-sdk/rest | browser and Node | GameRestClient: every GameClient REST method, without join(), so socket.io stays out of your bundle (about 2 KB instead of 15 KB gzipped) |
| gameon-sdk/server | Node only | GameOnPlatform (calls to the platform), gameRoutes (the standard endpoints), gameError |
gameon-sdk/server uses node:crypto and holds your private key. Never import it in browser code.
Install
npm install gameon-sdkNode 18 or newer (uses the built-in fetch). Both entries work with import (ESM) and require (CommonJS).
How a game fits together
GameOn platform ──createRoom──▶ game backend ──play links──▶ players
▲
game page: launch ticket ──exchangeLaunchTicket──▶ sessionToken
REST + socket requests carry the sessionToken
│
game backend: verifies the token, runs the game, then reports the result
and moves coins on the platform (signed with your private key)A player's uuid is visible in play links, so it never proves who someone is. The sessionToken does: the game page gets it once from the launch ticket and sends it with every request that acts for the player.
Game frontend: GameClient and GameConnection
import { GameClient, GameOnError, isSessionExpired, SESSION_EXPIRED } from 'gameon-sdk';
const game = new GameClient({ backendUrl: 'https://connect4.example.com' });
// 1. Identify the player: the platform hands the game page a one-time launch ticket
const { sessionToken } = await game.exchangeLaunchTicket(launchTicket);
// 2. REST
const { gameDetails } = await game.getGameDetails(gameSessionUuid);
const { balance } = await game.getBalance(playerUuid, sessionToken); // sent as Authorization: Bearer
// 3. Realtime: join with the token, then register listeners
const connection = game.join({ gameSessionUuid, playerUuid, playerName: 'Alice', sessionToken });
connection.onGameUpdate((state) => render(state));
connection.onGameOver((result) => showResult(result));
connection.onError((code) => showError(code === SESSION_EXPIRED ? 'Your session expired. Please reopen the game.' : code));
connection.onDisconnect(() => setOffline(true));
connection.onConnect(() => setOffline(false));
// 4. Actions return false while disconnected: nothing is sent or queued
if (!connection.makeMove({ col: 3 })) setOffline(true);
// 5. Leaving on purpose
try {
await game.userBack(gameSessionUuid, playerUuid, sessionToken);
} catch (error) {
if (isSessionExpired(error)) showError('Your session expired. Please reopen the game.');
else if (error instanceof GameOnError) console.error(error.status, error.code); // e.g. 404 'E1008'
}Session expiry: a sessionToken is only valid for a limited time, set by the platform. When it is invalid or has expired, REST calls fail with 401 and errorCode E1010 (SESSION_EXPIRED; check with isSessionExpired(error)), and a socket join gets an error event with that code. The launch ticket works only once, so the player has to reopen the game from the GameOn platform.
join() sends joinGame again after every reconnect, so register listeners right after calling it.
GameClient methods
| Method | Notes |
| --- | --- |
| exchangeLaunchTicket(launchTicket) | → { sessionToken }. Do this once per session |
| health() | |
| createRoom(input) | → { gameSessionUuid, links, gameStateId?, raw } |
| getGameDetails(gameSessionUuid) | → { gameDetails } |
| userBack(gameSessionUuid, uuid, sessionToken) | The player leaves; the backend resolves the game |
| updateGameType(gameSessionUuid, 'BOT_GAME', sessionToken) | Only BOT_GAME can be set from a browser |
| getBalance(userUuid, sessionToken) | The player's own balance only |
| isBot(uuid) | Needs gameonBackendUrl in the options |
| request(method, path, body?, query?) | Any game-only endpoint |
Betting games (roulette, crash, plinko, ...) also have getConfig(), getState(), getHistory(limit?), getFxRate(), and these, which act for the player:
| Method | Notes |
| --- | --- |
| placeBet({ sessionToken, bets, ... }) | The bet shape is the game's own |
| getMyBets(uuid, sessionToken) | The player's bets in the current round |
| getMyHistory(uuid, limit?, sessionToken) | The player's past rounds |
| getPlayer(uuid, sessionToken) | The player's record on this game |
Results whose shape is the game's own (getConfig, getState, getHistory, placeBet, getMyBets, getMyHistory, getPlayer, request) are unknown until you say what they are, e.g. game.getConfig<RouletteConfig>(), so a typo in a field name doesn't compile. A backend that is not on the standard paths yet can be reached with new GameClient({ backendUrl, paths: { createRoom: '/api/createRoomV2' } }). A server that talks to many games can use GameOnClient, which keeps one GameClient per configured game.
Every request times out after 15 s by default (new GameClient({ backendUrl, timeoutMs: 10_000 }); 0 turns it off) and then rejects with a TimeoutError, so a loading screen can offer "try again". A timed-out placeBet may still have been placed: check getMyBets before placing it again.
GameConnection
| Actions (return true if sent, false while offline) | Events |
| --- | --- |
| makeMove(move) | onJoined, onSpectatorJoined |
| startGame(), resetGame(), startTimerAfterPopup() | onGameUpdate(state), onGameOver(result) |
| reportTimeout(timeoutPlayer) | onTimeoutNotification, onAutoMove, onReconnectionTimer, onWaitingUpdate, onReset |
| send(event, data) for game-only events | onError(code), on(event, handler) for game-only events |
| disconnect(), reconnect(), connected | onConnect, onDisconnect, onConnectError, onReconnectFailed |
Every on... returns a function that removes the listener. gameSessionUuid is added to every action for you.
A dropped connection keeps retrying (backing off to one try every 5 s) and, in a browser, retries as soon as the page becomes visible again, so a game left in the background on a phone recovers when the player returns. joinGame is sent again and every listener stays registered. reconnect() does the same on demand, e.g. from a "Reconnect" button. disconnect() closes the connection for good and removes every listener it registered (your onDisconnect handlers still hear it), so a long-lived page can join() many times without piling up listeners. To play again after disconnect(), call join() again; reconnect() then throws. To give up after a number of tries instead, pass socketOptions: { reconnectionAttempts: 10 } and handle onReconnectFailed.
Audit trail: the player's side (game.telemetry)
Disputes ("I lost because of your system") are settled with two records of each game session, kept side by side by the GameOn platform. The game server's audit log is the authoritative one (see Audit log below). It can't be changed or blocked by the player, and it keeps recording when their connection drops. This one is what the player's browser saw. It supports the server's record but can't replace it, because the player controls the browser.
It starts by itself once the client has a sessionToken, from exchangeLaunchTicket or join() with one. It needs gameonBackendUrl, because events go straight to the platform, never through the game backend. Mouse and touch input is the only part that needs the player's consent:
const game = new GameClient({ backendUrl: 'https://connect4.example.com', gameonBackendUrl: 'https://platform.example.com' });
const { sessionToken } = await game.exchangeLaunchTicket(launchTicket); // recording starts here
const connection = game.join({ gameSessionUuid, playerUuid, sessionToken });
game.telemetry?.setPointer(true); // only after the player agreed; setPointer(false) when they withdraw
game.telemetry?.track('level_start', { level: 3 }); // the game's own events| type | What | Consent |
| --- | --- | --- |
| action | Every REST call except GETs (path, body, HTTP status), and every socket action, including joinGame (event, data, whether it was sent) | not needed |
| received | Server events that explain an outcome: error codes, joined, autoMove, mainTimerTimeoutNotification, reconnectionTimer, resetGame, gameOver | not needed |
| connection | Connect, disconnect (with the reason), connect error, reconnect gave up | not needed |
| latency | The round trip to the game server every 5 s while connected (latencySampleMs on join()), with the server's clock. Needs the server's audit log, which answers it | not needed |
| network, visibility | The browser went offline or online; the page was hidden or shown | not needed |
| error | Uncaught errors and unhandled promise rejections (message, stack, file, line) | not needed |
| pointer | Mouse, pen and touch: presses, releases, and movement sampled every 100 ms. Positions are fractions (0..1) of the window | setPointer(true) |
Every event has a unique id, and every batch has the time it was sentAt, so the platform can drop duplicates and correct a wrong clock on the player's device. The sessionToken and launchTicket are never recorded. Events are posted in batches of up to 100 to the platform's POST /api/sdk/v1/game-session/telemetry (see specs/platform.yaml) every 5 s, and when the page is hidden or closed.
Nothing is lost when the connection drops. Whatever hasn't reached the platform is also kept in localStorage (pointer movement excepted) and sent when the browser is back online, or on the next page load, up to 7 days later. Only the events are saved, never the sessionToken: saved events are sent with the token of the page that sends them, so they still arrive after the earlier token has expired. The platform keeps only those whose game session belongs to that token's player. Recording stops by itself when the platform answers 401, 403, 404 or status: false. game.telemetry.stop() sends what's left; stop(false) throws it away, from localStorage too.
Game backend: gameRoutes and GameOnPlatform
import express from 'express';
import { createServer } from 'node:http';
import { Server } from 'socket.io';
import { GameOnPlatform, GameOnPlatformError, gameRoutes, gameError, SESSION_EXPIRED } from 'gameon-sdk/server';
const platform = new GameOnPlatform({ audit: { game: 'connect4' } }); // reads GAMEON_BACKEND_URL, DEVELOPER_ID, PRIVATE_KEY
// The standard REST endpoints: supply your game's logic, the SDK does the rest
const app = express();
app.use(express.json());
app.use(gameRoutes({
game: 'connect4',
platform,
async createRoom({ room, players }) {
if (await rooms.exists(room.gameSessionUuid)) throw gameError('E1006', 'Game session already exists');
await rooms.create(room, players);
return {
gameSessionUuid: room.gameSessionUuid,
links: players.map((p) => `https://play.example.com/?gameSessionUuid=${room.gameSessionUuid}&uuid=${p.uuid}`),
};
},
async getGameDetails(gameSessionUuid) {
return rooms.find(gameSessionUuid); // null → 404 E1008
},
async userBack(gameSessionUuid, uuid) { // uuid is the verified player
await rooms.playerLeft(gameSessionUuid, uuid);
},
}));
// Realtime: verify the join, then trust only the uuid kept on the socket
const http = createServer(app);
const io = new Server(http);
io.on('connection', (socket) => {
socket.on('joinGame', async (data) => {
try {
const { uuid } = await platform.verifyJoin(data);
socket.data.playerUuid = uuid;
socket.join(data.gameSessionUuid);
} catch (error) {
// Unreachable or 5xx: a platform outage, not the player's fault. 403: the token
// belongs to another player. Other platform answers: missing, invalid or expired.
const code = !(error instanceof GameOnPlatformError) || error.status >= 500 ? 'E5000'
: error.status === 403 ? 'E1002' : SESSION_EXPIRED;
socket.emit('error', { code });
}
});
socket.on('makeMove', async (move) => {
if (!socket.data.playerUuid) return socket.emit('error', { code: 'E1002' });
await games.applyMove(move.gameSessionUuid, socket.data.playerUuid, move); // never a uuid from `move`
});
});
http.listen(3002);
// When a game ends: tournaments go to the match result, everything else to game_session_finish
await platform.reportGameResult({
gameSessionUuid,
gameType: 'MULTIPLAYER',
gameStatus: 'FINISHED',
players: [{ uuid: winnerUuid, winState: 'WON' }, { uuid: loserUuid, winState: 'DEFEATED' }],
});
// Moving coins: one idempotency key per move, and the SAME key on every retry
try {
await platform.updateUserCoins({ uuid: winnerUuid, actionType: 'WIN', amount: 100, sessionUuid, idempotencyKey: `win-${sessionUuid}` });
} catch (error) {
// A timeout does not mean the coins did not move. Retry later with the same key.
}gameRoutes also works without express.json() (it then reads bodies itself, up to 1 MB) and with a plain node:http server.
Audit log: the record disputes are settled with
platform.audit records what the game server saw and decided, per player and game session, with the server's clock, and sends it signed to the GameOn platform. That platform shows it next to what the player's browser saw (above). The player can't change or block it, so in a dispute it's the record to rely on: "the server lost your connection at 14:02:10, your browser went offline at 14:02:09, and the auto-move fired at 14:02:40 under the timeout rules."
Most of it is recorded for you:
| Recorded by | Events |
| --- | --- |
| GameOnPlatform itself | entered (launch ticket exchanged), session_rejected, join / join_rejected (from verifyJoin; every join is recorded, and the platform marks later ones as rejoins), coins (stake held, coins moved), result (from reportGameResult, with its reason and whether the report went through) |
| gameRoutes | accepted / rejected for every action it serves (bet, userBack, updateGameType, createRoom), with the verified player and the reason |
| platform.audit.attach(io) | connect, disconnect (with socket.io's reason), join_requested, received (every event from the player), sent (outcome events emitted to that socket: errors, autoMove, timers, gameOver). Also answers the client's latency samples |
What only the game knows, the game records. Pass the socket, and the player and game session are filled in from verifyJoin:
const audit = platform.audit;
audit.attach(io); // before your own io.on('connection')
io.on('connection', (socket) => {
socket.on('joinGame', async (data) => {
socket.data.playerUuid = (await platform.verifyJoin(data, socket)).uuid; // pass the socket
});
socket.on('makeMove', (move) => {
const problem = game.check(move, socket.data.playerUuid);
if (problem) {
audit.record('rejected', { socket, event: 'makeMove', move, reason: problem });
return socket.emit('error', { code: 'E2001' });
}
audit.record('accepted', { socket, event: 'makeMove', move });
});
});
// Timers and what they did, e.g. from your turn timer
audit.record('timeout', { gameSessionUuid, playerUuid, turn: 12, limitSeconds: 30 });
audit.record('reconnection_timer_started', { gameSessionUuid, playerUuid, seconds: 30 });
audit.record('reconnection_timer_expired', { gameSessionUuid, playerUuid });
audit.record('auto_move', { gameSessionUuid, playerUuid, move: { col: 4 }, rule: 'turn timer ran out' });
// The result, and how it was decided
await platform.reportGameResult({ gameSessionUuid, gameType, gameStatus: 'FINISHED', players, reason: 'forfeit: bob did not reconnect in 30 s' });
process.on('SIGTERM', async () => { await audit.close(); process.exit(0); }); // send what is leftRoom broadcasts (io.to(room).emit(...)) aren't seen by attach, so record those outcomes yourself. Batches go out every 2 s. While the platform is unreachable, they're kept (up to 10,000 events) and retried, and the outage is logged once. new GameOnPlatform({ audit: { mirror: (event) => logger.info(event) } }) also hands every event to your own logs, as a second copy. audit: false turns it off (e.g. in tests). Every batch names the game: the platform gameRoutes creates uses its game, and a platform you create names it with audit: { game: 'connect4' }. Sending is signed, so it needs DEVELOPER_ID and PRIVATE_KEY.
Cheating is caught on the server, not in the browser. A cheater can switch off or change anything their browser sends. So the server checks every move (and records the verdict), the server decides the result, and results are reported signed. Pointer data may later help to spot bots, but only as a weak signal.
Configuration
| Environment variable | GameOnPlatform option | Used for |
| --- | --- | --- |
| GAMEON_BACKEND_URL | baseUrl | every platform call |
| DEVELOPER_ID | developerId | signed calls |
| PRIVATE_KEY (PEM, or a path to one) | privateKey | signed calls |
| | timeoutMs (default 15 s), coinTimeoutMs (default 60 s) | |
| | allowInsecure | allows an http:// platform URL (see below) |
The environment is read when first needed, so dotenv may load after the client is created.
URLs must use https. Signed calls and session tokens must not travel in cleartext, so GameOnPlatform, GameClient and GameConnection refuse an http:// / ws:// URL to any other machine with an error. localhost, 127.x, ::1 and *.localhost are fine, as are relative URLs ('', /api) in a page. For a network you trust (e.g. a test environment), pass allowInsecure: true.
Endpoints served by gameRoutes
An endpoint is served only when you supply its handler. Anything else falls through to your own routes.
| Endpoint | Handler | Checked by the SDK before your handler runs |
| --- | --- | --- |
| GET /api/health | built in | |
| POST /api/exchange-launch-ticket | built in (relayed to the platform) | launchTicket is a string |
| GET /api/getUserWalletBalance | built in (platform balance), or getBalance | sessionToken (Bearer); only the token's own balance |
| POST /rooms (also /api/createRoom) | createRoom(input) | platform token (Bearer); room and players[] shapes, string ids; or the matchmaking body { sessionId, players: [{ playerSessionKey, userId }] }. Answers with a top-level stateId |
| GET /api/getGameDetails | getGameDetails(gameSessionUuid) | |
| POST /api/userBack | userBack(gameSessionUuid, uuid) | sessionToken; uuid is the token's player |
| POST /api/updateGameType | updateGameType(gameSessionUuid, gameType, playerUuid) | sessionToken; only BOT_GAME |
| GET /api/game/config, /state, /history, /api/fx/rate | getConfig, getState, getHistory, getFxRate | |
| POST /api/game/bet | placeBet(bet, playerUuid) | sessionToken; playerUuid is the token's player |
| GET /api/game/mybets, /myhistory, /api/users/:uuid | getMyBets, getMyHistory, getPlayer | sessionToken (Bearer); only the token's own data |
Set GAMEON_PLATFORM_TOKEN (or platformToken) for createRoom and keep it server-side only; with no token set, createRoom answers 500.
Throw gameError(code, message, status?) from a handler to answer with { status: false, errorCode, message }. Unexpected errors become 500 E5000 and are logged, never sent to the client. aliases keeps older paths working, e.g. { '/api/createRoom': ['/api/createRoomV2'] }.
GameOnPlatform methods
| Method | Notes |
| --- | --- |
| exchangeLaunchTicket(ticket), validateSessionToken(token) | Who the player is |
| verifyJoin(joinGameData) | For the socket joinGame handler → { uuid } |
| reportGameResult(result) | Signed. Picks the tournament or normal path |
| finishGameSession(input), reportTournamentResult(matchId, input) | Signed. The two paths, if you need them directly |
| createUserGameSession(input) | Creates the player's session and holds the stake |
| updateUserCoins(input) | Signed. RELEASE, CAPTURE or WIN, with an idempotencyKey |
| getUserCoinBalance(uuid) | Signed |
| isBot(uuid), getExchangeRate(currency?) | |
Result reports, createUserGameSession and updateUserCoins return a PlatformResponse (status, content?, message?, error_code?). content is unknown unless you pass its type, e.g. createUserGameSession<{ sessionId: string }>(...). Platform errors are GameOnPlatformError (status, body, errorCode). A failed updateUserCoins error also carries the idempotencyKey it sent, for the retry. Signed calls send X-Developer-ID, X-Signature, and the replay-protection headers X-Timestamp, X-Nonce and X-Signature-V2 (see specs/platform.yaml).
Security: what every game must do
The SDK checks what it can at the edge. These rules are still the game's job:
- Identity comes from the sessionToken, never from a uuid in a request. Use
verifyJoinon sockets and keep the returned uuid on the socket. In handlers, use the uuid the SDK passes you (userBack,placeBet,getMyBets, ...). - Send the sessionToken from the frontend on every call that acts for the player (see the tables above).
- One
idempotencyKeyper coin move, reused on every retry of that move. - Keep
PRIVATE_KEYon the server. Anything imported fromgameon-sdk/serverstays out of browser bundles. - Record your decisions in the audit log: every move accepted or rejected (with the reason), timers, auto-moves, and the result's
reason. Callplatform.audit.attach(io)and pass the socket toverifyJoin. In a dispute, this is the record.
While callers are being updated, gameRoutes has temporary switches that accept requests without a sessionToken (or platform token) and log a warning each time: allowUnverifiedRoomActions (userBack, updateGameType), allowUnverifiedPlayerData (balance, bets, history, player) and allowUnverifiedCreateRoom (createRoom; a wrong token is still refused). Remove them once the warnings stop.
Error codes
| Code | Meaning |
| --- | --- |
| E1001 | Invalid game session |
| E1002 | Not authorized |
| E1003 | Bot players cannot make manual moves |
| E1004 | Game has already ended |
| E1005 | Game room is full |
| E1006 | Game session already exists |
| E1007, E1009 | Missing field |
| E1008 | Room not found |
| E1010 | Session token invalid or expired (SESSION_EXPIRED): the player reopens the game |
| E5000 | Internal server error |
| E0000 | Unknown socket error |
Contracts
specs/gameon.yaml: what every game backend serves (REST endpoints and socket events).specs/platform.yaml: the GameOn platform calls, including signing.
Development
npm install
npm test # unit and security tests (vitest) + typecheck, no servers needed
npm run typecheck # type-check src (tsconfig.json) and tests + harness (tsconfig.test.json)
npm run lint # oxlint (.oxlintrc.json); also runs in CI, any finding fails the build
npm run build # compile to dist/ (ESM, plus CommonJS index.cjs and server.cjs)
npm run test:package # build, then load both entries with require() and import
GAME_URL=http://localhost:3002 npm run test:live # smoke test against a running game backendEvery change that affects games gets a line under Unreleased in CHANGELOG.md, in the same PR.
Release:
- Pick the version by semver: major if something can break for games (they must change code or config), minor for new features, patch for fixes only.
- On a branch from an up-to-date
main, set the version and update the changelog, then open a PR as usual:
git checkout -b release/X.Y.Z
npm version X.Y.Z --no-git-tag-version # sets package.json and package-lock.json only
# in CHANGELOG.md: rename "Unreleased" to "X.Y.Z - <today>", add a new empty "Unreleased" above it,
# and update the compare links at the bottom
git commit -am "chore: release X.Y.Z"
git push -u origin HEAD- After the PR is merged, tag the merged commit on
mainand publish from it:
git checkout main && git pull
git tag -a vX.Y.Z -m "X.Y.Z"
git push origin vX.Y.Z
npm publish # runs test:package (build + require/import check) first