@ksaa-nlp/gamehub-sdk
v0.9.0
Published
ESM SDK that lets GameHub-hosted games talk to the platform (auth, sessions, scores, multiplayer). Drops into Node, Angular, browsers, or a `<script>` tag.
Readme
GameHub SDK v0.4.0 — Official Developer Guide
GameHub SDK is an ESM SDK used by GameHub-hosted games to communicate with the platform for authentication, sessions, score submission, league matches, questions, and multiplayer rooms.
It runs in any modern JavaScript environment — browser, Node, Angular Universal / SSR, Vite, webpack, esbuild, Bun. It also ships a self-contained IIFE bundle for plain <script>-tag use.
Install
Package-based games (Angular, React, Vue, Phaser, Vite, webpack, ...)
npm install @ksaa-nlp/gamehub-sdkimport { createSdk, type Sdk } from '@ksaa-nlp/gamehub-sdk';
const sdk: Sdk = createSdk();
await sdk.ready();
await sdk.submitScore(1500);Importing the package has no side effects — createSdk() builds an SDK instance you control. Calling any method that needs platform state (e.g. ready(), submitScore()) lazily triggers the bootstrap handshake the first time it's needed.
The package is ESM-only. Modern Node (≥18), Angular CLI, Vite, webpack 5+, esbuild, Bun, and Rollup all consume ESM natively. Tooling that requires CJS require() needs a transpile step or a dynamic import().
Server-side rendering (Angular Universal, Next-style SSR, Node services)
The package is safe to import on the server — no window / document access happens at evaluation time. Methods called server-side return sensible defaults instead of throwing:
const sdk = createSdk();
// Safe on the server — no ReferenceError, no exception.
sdk.isInIframe(); // false
sdk.getBootstrap().isBootstrapped; // false
sdk.notifyComplete({ score: 0 }); // silently no-ops
await sdk.ready(); // resolves to falseYou don't need isPlatformBrowser / typeof window gates around the import or around createSdk().
GameHub ZIP uploads
When you upload a ZIP game, GameHub injects the SDK into the entry HTML automatically:
<script src="/sdk/gamehub-sdk.js" data-game-slug="your-game-slug"></script>This loads the IIFE bundle, attaches window.GameHubSDK, and runs the auto-init handshake on DOMContentLoaded. Do not add your own wrapper layer around the SDK. Use the public methods directly and wait for ready().
Local standalone testing
If you run the game outside GameHub and are not bundling the npm package, add this script tag only for local testing:
<script src="https://gamesbeta.ksaa.gov.sa/sdk/gamehub-sdk.js"></script>Remove the tag before uploading if your build already imports the package or if GameHub will inject it.
Required boot pattern
There is no separate init() call. The SDK auto-detects game context from the script tag (or your import), URL, and host postMessage bootstrap payload — await sdk.ready() runs the handshake and resolves when it's done.
import { createSdk } from '@ksaa-nlp/gamehub-sdk';
const sdk = createSdk();
await sdk.ready();
const bootstrap = sdk.getBootstrap();
console.log({
version: sdk.version,
gameSlug: bootstrap.gameSlug,
leagueId: bootstrap.leagueId,
matchId: bootstrap.matchId,
peerId: bootstrap.peerId,
room: bootstrap.room,
});For <script>-tag games:
await window.GameHubSDK.ready();
const bootstrap = window.GameHubSDK.getBootstrap();Bootstrap data
sdk.getBootstrap() returns the current runtime state:
{
gameSlug: string | null;
authToken: string | null;
leagueId: string | null;
gameId: string | null;
matchId: string | null;
match: MatchContext | null;
participant1Id: string | null;
participant2Id: string | null;
participant1MemberIds: string[];
participant2MemberIds: string[];
playerName: string | null;
peerId: string | null;
room: BootstrapRoom | null; // null for non-multiplayer sessions
isBootstrapped: boolean;
}For live peer/room state, use sdk.multiplayer.getRoom(), sdk.multiplayer.getPeers(), and the peer_joined / peer_left events — bootstrap intentionally doesn't expose live counts because they'd be stale by the time you read them.
Score submission
Call submitScore when a standalone game round ends:
const result = await sdk.submitScore(score, {
mode: 'single',
correctAnswers: 8,
totalQuestions: 10,
durationMs: 92000,
level: 3,
});
if (result.skipped) {
// In an online room, this peer is not the host. The host will submit.
} else if (!result.success) {
console.error(result.error);
}submitScore returns a SubmitScoreResult:
{
success: boolean;
accepted?: boolean;
timeTaken?: number;
error?: string;
skipped?: boolean;
}Inside a multiplayer room, only the host can write scores. Guests and viewers receive { success: true, skipped: true } without a network write.
League match integration
When a game is opened from a league match, the host supplies leagueId, gameId, and matchId. The SDK uses these values to start a scoped match session.
Fetch participant names and team members:
await sdk.ready();
const match = await sdk.getMatchContext();
if (match) {
renderSideA(match.participant1.name, match.participant1.members);
renderSideB(match.participant2.name, match.participant2.members);
}MatchContext:
{
id: string;
leagueId: string;
gameId: string;
participant1: {
participantId: string;
name?: string;
members?: Array<{ leaguePlayerId: string; fullname?: string; avatar?: string }>;
};
participant2: {
participantId: string;
name?: string;
members?: Array<{ leaguePlayerId: string; fullname?: string; avatar?: string }>;
};
status: string;
}Submit the match winner and scores:
await sdk.submitMatchResult({
winnerId: match.participant1.participantId,
scores: {
[match.participant1.participantId]: 12,
[match.participant2.participantId]: 8,
},
reason: 'normal_finish',
details: {
durationMs: 94000,
},
});For a match-specific score without a full bracket result:
await sdk.submitMatchScore(1200, {
correctAnswers: 10,
totalQuestions: 12,
});In multiplayer league matches, the host should submit the match result. If shared game code calls submitMatchResult from a guest or viewer, the SDK returns skipped.
Questions
Fetch published questions for the active game:
const questions = await sdk.getQuestions({
difficulty: 'medium',
category: 'science',
limit: 20,
});The SDK uses gameId when provided by the host. Otherwise it falls back to the game slug.
Question shape:
{
id: string;
difficulty?: 'easy' | 'medium' | 'hard' | string;
category?: string;
prompt: string;
choices: string[];
answer: string | number;
explanation?: string;
points: number;
}Invalid options or API failures return an empty array.
Multiplayer model
GameHub multiplayer is host-authoritative:
| Concern | Owner | | -------------------------------- | --------------------------------------------- | | Random grids, seeds, decks | Host | | Turn order and round advancement | Host | | Click/move validation | Host | | Player input | Local player sends intent | | Final score and match result | Host only | | Timer | Host broadcasts or sends an absolute deadline |
Guests send intent with send. The host validates and publishes resolved state with broadcast.
Create and join
const room = await sdk.multiplayer.createRoom({
maxPlayers: 2,
passcode: '1234',
});
console.log(room.roomCode, room.shareUrl);await sdk.multiplayer.joinRoom('AB12CD', {
passcode: '1234',
displayName: 'Player 2',
});
await sdk.multiplayer.joinRoomByLink(window.location.href);
// Read-only guest:
await sdk.multiplayer.joinRoom('AB12CD', { asViewer: true });Auto-join on boot
The SDK auto-joins when it can detect a pending room — you don't need to wire any of this yourself:
| Signal | Where it comes from |
| ------------------------------------------------------------ | ---------------------------------------------------------------------- |
| ?mp=<shareId> on the page URL | QR scan or shared link — SDK auto-calls joinRoomByLink. |
| ?roomCode=<CODE>&playerName=<name> | Host UI forwarded this after join-token — SDK auto-calls joinRoom. |
| GAMEHUB_SDK_BOOTSTRAP postMessage (shareId / roomCode) | Host page pushed them into the iframe. |
By the time await sdk.ready() resolves, your getBootstrap() already contains peerId and room, and the socket is connecting. The bootstrap does not contain the peer list or live room state — those would be stale by the time you read them. Use multiplayer.getRoom(), multiplayer.getPeers(), and listen to connected / room_joined / peer_joined / peer_left for live data.
Send and receive game events
Guests send intent with send(). The host validates and publishes resolved state with broadcast() — host-only; returns false for guests and viewers.
const mp = sdk.multiplayer;
function onCellClick(cellId: string) {
mp.send('cell_click', { cellId });
}
mp.on('cell_click', ({ fromPeerId, payload }) => {
if (!mp.isHost()) return;
const result = resolveMove(fromPeerId, payload.cellId);
mp.broadcast('cell_resolved', result);
});
mp.on('cell_resolved', ({ payload }) => {
applyResolvedMove(payload);
});Role helpers
sdk.multiplayer.getRole(); // 'host' | 'player' | 'viewer' | null
sdk.multiplayer.isHost();
sdk.multiplayer.isViewer();
sdk.multiplayer.selfId();
sdk.multiplayer.getRoom();
sdk.multiplayer.getPeers();
sdk.multiplayer.isConnected();
sdk.multiplayer.leaveRoom();Events
Subscribe with sdk.multiplayer.on(eventName, handler). Use '*' to observe all events.
| Event | Payload | Meaning |
| -------------- | ------------------------------------------------- | --------------------------- |
| connected | { roomCode, peerId } | WebSocket opened |
| disconnected | { roomCode } | WebSocket closed |
| reconnecting | { attempts, delayMs } | SDK is reconnecting |
| room_joined | { peerId, role, peers } | Local peer joined |
| peer_joined | { peerId, role, ... } | Another peer joined |
| peer_left | { peerId } | A peer left |
| room_closed | { reason } | Host/server closed the room |
| room_left | { reason } | Local peer left |
| error | { code } | Server rejected a frame |
| message | { eventName, payload, fromPeerId, seq, sentAt } | Any custom game event |
Custom game events are also emitted by their own name:
mp.on('round_resolved', ({ payload, fromPeerId, seq }) => {
applyRound(payload);
});The SDK drops late or duplicate game-event frames using the server-provided seq per sending peer.
Public API reference
Core:
version: stringready(): Promise<boolean>isInitialized(): booleanisAuthenticated(): booleanhasActiveSession(): booleanstartSession(): Promise<StartSessionResult>submitScore(score, meta?): Promise<SubmitScoreResult>submitMatchScore(score, meta?): Promise<SubmitScoreResult>submitMatchResult(result?): Promise<SubmitScoreResult>playAgain(): Promise<{ success: boolean; token: string | null }>notifyComplete(data?): voidrequestClose(): voidlogEvent(eventName, eventData?): voidgetGameSlug(): string | nullisInIframe(): booleangetBootstrap(): BootstrapDatagetMatchContext(): Promise<MatchContext | null>getQuestions(options?): Promise<Question[]>
Multiplayer:
multiplayer.createRoom(options?)multiplayer.joinRoom(roomCode, options?)multiplayer.joinRoomByLink(link, options?)multiplayer.leaveRoom(reason?)multiplayer.send(eventName, payload?)multiplayer.broadcast(eventName, payload?)multiplayer.on(eventName, handler)multiplayer.getRoom()multiplayer.getPeers()multiplayer.isConnected()multiplayer.getRole()multiplayer.isHost()multiplayer.isViewer()multiplayer.selfId()
Type imports
Every public type is exported as a named ESM type from the package root. Import what you need:
import type {
Sdk,
BootstrapData,
BootstrapRoom,
MatchContext,
MatchParticipant,
MatchResultPayload,
RoomInfo,
Peer,
RoomOptions,
JoinOptions,
CreateRoomResult,
JoinRoomResult,
ScoreMetadata,
Question,
QuestionOptions,
StartSessionResult,
SubmitScoreResult,
MpMessage,
MpEventHandler,
} from '@ksaa-nlp/gamehub-sdk';Pause and resume
If the host pauses/resumes the iframe, define these optional handlers:
window.onGameHubPause = function () {
pauseGame();
};
window.onGameHubResume = function () {
resumeGame();
};Common issues
window.GameHubSDK is undefined
You're on the <script>-tag path but the bundle didn't load. Either:
- The script tag is missing from your HTML — re-add
<script src="/sdk/gamehub-sdk.js">(or the local-testing CDN URL). - You're inside a bundled app and never imported the SDK — import
@ksaa-nlp/gamehub-sdkand callcreateSdk()instead. The npm path doesn't attachwindow.GameHubSDK; that's only for the script-tag flow.
import sdk from '@ksaa-nlp/gamehub-sdk' fails or returns undefined
The default-import singleton was removed in v0.2.0. Use the named factory:
import { createSdk } from '@ksaa-nlp/gamehub-sdk';
const sdk = createSdk();ReferenceError: window is not defined at import time
You're on a version older than v0.3.0. Upgrade to v0.4.0 or later — importing the package no longer touches window / document. If you still see this on a current version, something is importing the script-tag entry (@ksaa-nlp/gamehub-sdk/browser or dist/gamehub-sdk.js) on the server. Use the default . entry instead.
Cannot use import statement outside a module / CJS require() fails
The package is ESM-only. Set "type": "module" in your package.json, or use a dynamic import() from CJS code.
Score returns skipped
The current peer is not the multiplayer host, or it joined as a viewer. This is expected and prevents duplicate writes.
Match context is null
The game was not launched with leagueId and matchId, or the match endpoint could not resolve the match. Fall back to normal game UI.
Questions return []
No published questions matched the filters, options were invalid, or the API request failed. Treat this as the empty/degraded state.
Guest state differs from host
Move all random generation and final state transitions to the host. Guests should send intent only and wait for broadcasted resolved events.
Versions
- CHANGELOG.md — release notes for every version.
- MIGRATION.md — upgrading from v0.1 / v0.2.x / v0.3.x.
