SDK reference
Everything a game can say to the portal and everything the portal can say back. The tables are generated from the real schemas, constants and rules in @gameportal/sdk-types, so they cannot drift from the code; the prose around them is hand-written. If the words handshake, port or standalone are new to you, read Concepts first.
Not
Samples call functions such as startGame() and freezeGame(). They stand for your own game code. Every sample on this page is type-checked against the real packages by a test, so the code you copy compiles.
Packages #
| Package | What it is | You use it for |
|---|---|---|
@gameportal/game-bridge | The game side of SDK v1: handshake, saves, scores, events, pause, language, standalone mode. | Every game that talks to the portal (Level 1 and 2). |
@gameportal/sdk-types | Message types, the zod schemas of every message, the constants and limits, the manifest schema, measureJson. | Types in your game, validation in your tests. |
@gameportal/game-kit | Optional, opinionated building blocks and templates on top of the bridge (loop, input, audio, seeded RNG, an arcade host). | Games that fit its templates (one-button arcade, drag-aim, idle, grid puzzle, cards). Its README is packages/game-kit/README.md. |
@gameportal/game-tools | create:game, the dev console, validate and check. | Your daily workflow, see Testing. |
@gameportal/rules | The platform rule validators and the fake-portal runner. | Run through game-tools; see Platform rules. |
Today these are packages of the GamePortal repository; a game lives next to them in games/<id>. They are published for external developers when the programme opens (V2), with the same names and the same API.
The bridge #
createBridge() is the only thing most games need. It does the handshake, keeps the save, paces everything under the platform limits and falls back to standalone mode when the page is opened on its own.
import { createBridge } from "@gameportal/game-bridge";
const bridge = createBridge({ gameId: "my-game", gameVersion: "1.0.0", saveVersion: 1 });
// Register every handler BEFORE start() so that no message is missed.
bridge.on("pause", () => freezeGame());
bridge.on("resume", () => showTapToContinue());
bridge.on("lang", ({ lang }) => setLanguage(lang));
bridge.start();
// "portal", "standalone" or "unsupported". Inside the portal this waits for `init`, however long it takes.
const mode = await bridge.whenSettled();
if (mode === "unsupported") {
showCalmScreen();
} else {
const saved = bridge.storage.load(); // the cloud save in the portal, localStorage when standalone
startGame(parseSave(saved?.data), bridge.init?.lang);
bridge.ready(); // the first frame is up and input works
}createBridge(options) #
| Option | Type | Meaning |
|---|---|---|
gameId | string | The manifest id. Sent in hello; also the prefix of the standalone storage key. |
gameVersion | string | The manifest version. Sent in hello. |
saveVersion | number | The format version of your save (manifest saveVersion). Sent with ready and every save. |
upgradeSave | (data, fromVersion) => unknown | Optional. Turns a save written in an older format into the current one. Called for the init save, for the server save of a conflict and for the standalone save (fromVersion is null there). Must not mutate its input. See save formats. |
portalOrigins | string[] | Optional. The portal origins the game may talk to. Defaults to the list games-host injects into every page (<meta name="gameportal:portal-origins">). "*" and malformed entries are dropped. |
standaloneStorageKey | string | Optional. The localStorage key in standalone mode. Defaults to <gameId>:save. Keep the <gameId>: prefix: every game shares one origin. |
window, now, helloIntervalMs, standaloneDelayMs, ackTimeoutMs, logger | For tests: inject a fake window and clock, or change the timings. Production code leaves them out. |
Properties and methods #
| Member | Meaning |
|---|---|
bridge.mode | booting (created), handshaking (inside an iframe, waiting for init), portal (init received), standalone (opened on its own), unsupported (the portal does not speak your SDK version). |
bridge.init | The init payload once received, otherwise null. It is replaced when the portal sends init again. |
bridge.start() | Starts the handshake (inside an iframe) or the 2 second standalone timer (outside). Safe to call once; later calls do nothing. |
bridge.whenSettled() | A promise of the mode: resolves with portal, standalone or unsupported. |
bridge.on(event, handler) | Subscribes to a bridge event and returns the function that unsubscribes. A throwing handler is isolated: it cannot break the bridge. |
bridge.ready() | Tells the portal the game is playable. Called before init arrived, it is queued and sent as soon as the port exists. Outside the portal it does nothing. |
bridge.score(mode, value) | Sends a score. Resolves with the ack payload, or null outside the portal. |
bridge.event(name, props?) | Sends an event. Resolves with the ack payload, or null outside the portal. |
bridge.session(active) | Sends session. Does nothing outside the portal. |
bridge.request(type, payload) | A raw save, score or event without any client-side pacing; for tests and tools. |
bridge.storage | The save: see storage. |
bridge.destroy() | Removes every listener and timer and closes the port. Tests use it; games normally never do. |
score() and event() do not pace themselves: at most one score per second and five events per second reach the portal without rate_limited. Space them in your game (the starter's src/pacing.ts does). A request that gets no ack within 15 seconds resolves as { ok: false, error: "network" }.
Bridge events #
| Event | Value | Fires when |
|---|---|---|
mode | the new mode | The mode changes (handshaking to portal, the 2 second timer to standalone, ...). |
init | { init, reinit } | init arrived. reinit is true for a later init over the port (the portal renewed an expired session); the first one is false. |
pause | { reason } | The portal paused the game (hidden, overlay or ad). The bridge also flushes the pending save at once. |
resume | { serverTime } | The pause ended. |
lang | { lang } | The player changed the language. |
day | { day, nextResetAt, challenge } | The session crossed the player's local midnight. |
save | a save status | Something happened to a save, see below. |
The save event carries one of these statuses:
status | Extra fields | Meaning | What the game does |
|---|---|---|---|
saved | revision | The portal stored the save. | Nothing. |
queued | reason: rate_limited, network, unauthorized or waiting_for_portal | The save is waiting and will be sent. | Nothing; at most show a quiet indicator. |
conflict | conflict | The server holds a different save (another tab or device). Saving is blocked until you decide. | Ask the player, or merge, then call storage.resolveConflict(). See conflicts. |
outdated | saveVersion | The server save was written by a newer format than this build knows. Saving has stopped for good in this page. | Tell the player to reload the page. |
error | error | too_large or invalid: the state is not retried. | Shrink or fix the state; keep your local copy. |
Storage #
bridge.storage is the only place a game keeps progress. In the portal it is the cloud save; standalone it is localStorage. The game's code is the same in both.
| Member | Meaning |
|---|---|
load() | The current save as { data, revision, savedAt, saveVersion }, or null. In the portal it comes from init; standalone from localStorage (revision, time and version are null there). data is already upgraded to your current format when you gave upgradeSave. Returns null when the server save is in a newer format (see outdated). |
save(data) | Stores the latest state. Standalone: written to localStorage at once. In the portal: sent at most once per limits.minSaveIntervalMs (30 s), and immediately on pause, on a hidden tab and on pagehide; only the newest state is kept while waiting. |
flush() | Sends the pending state now (the portal exempts one save per pause). The bridge calls it for you on pause and hidden tab. |
resolveConflict("local" | "server") | Answers a conflict: "local" keeps your state (sent on top of the server's revision) and returns it; "server" takes the server's save and returns it. Either way the portal keeps the other one in the save history. |
pending, revision, conflict, outdated | Read-only state: a save is waiting, the base revision, the open conflict (its data already upgraded), and whether the server save has a newer format. |
Conflicts #
A conflict means two copies of the progress exist and the portal will not decide which one wins: a player has the game open on a phone and a laptop, or signed in on a device that played as a guest. Handle it one of two ways.
Merge without asking when the save can only grow (best scores, unlocked items, settings): combine both and keep going. The starter does this.
bridge.on("save", (status) => {
if (status.status !== "conflict") return;
const merged = mergeSaves(bridge.storage.load()?.data, status.conflict.data);
bridge.storage.save(merged); // replaces the pending state while the bridge is blocked
bridge.storage.resolveConflict("local"); // sends it on top of the server's revision
});Ask the player when the two copies cannot be merged (a campaign position, an idle game's money). Show both, say how old they are (conflict.savedAt), and apply the choice:
bridge.on("save", (status) => {
if (status.status !== "conflict") return;
const choice = askPlayer(bridge.storage.load()?.data, status.conflict);
const chosen = bridge.storage.resolveConflict(choice);
startGame(parseSave(chosen), bridge.init?.lang);
});Whatever the player chooses, the other save stays in the server's save history (the newest five per player and game), so nothing is lost for good.
Save formats #
Every save carries the saveVersion of its format. When your format changes, raise saveVersion in the manifest and in createBridge(), and add one upgrade step per change. upgradeSaveData runs them in order:
import { createBridge, upgradeSaveData } from "@gameportal/game-bridge";
const STEPS = {
// version 1 -> 2: the single `coins` number became an object
1: (data: unknown) => ({ wallet: { coins: (data as { coins?: number }).coins ?? 0 } }),
// version 2 -> 3: a new field with a default
2: (data: unknown) => ({ ...(data as object), muted: false }),
};
const SAVE_VERSION = 3;
export const bridge = createBridge({
gameId: "my-game",
gameVersion: "1.2.0",
saveVersion: SAVE_VERSION,
upgradeSave: (data, fromVersion) => (fromVersion === null ? data : upgradeSaveData(data, fromVersion, SAVE_VERSION, STEPS)),
});A save from a newer format than the build is never loaded and never overwritten: the bridge emits outdated and stops saving, and the portal itself refuses the write with conflict. That protects the player when an old tab or a rolled-back release meets a save written by a newer one.
Protocol #
The bridge speaks the protocol for you. This section is the contract itself: for your tests, for engines that cannot use the bridge (Unity and similar) and for anyone who wants to know what crosses the wire.
Envelope #
Every message has the same shape: { sdk: "portal-sdk", v: 1, type, id, payload }.
| Field | Value | Meaning |
|---|---|---|
sdk | "portal-sdk" | Identifies the protocol. |
v | 1 | SDK major version of the message. |
type | string | The message type: hello, init, ready, save, ... |
id | UUID | Unique per message. An ack refers to it in payload.for. The bridge repeats the same hello id on every retransmission. |
payload | object | Depends on type; see the messages below. |
Limits #
| Limit | Value | Notes |
|---|---|---|
| Save size | 256 KB (262,144 bytes) | UTF-8 length of the compact JSON of save.data. Larger saves get too_large. |
| Save interval | 1 per 30 s | Plus one immediate save when pause arrives or the tab is hidden. The bridge paces this for you. |
| Score interval | 1 per 1 s | More often gets rate_limited. |
| Event rate | 5 per second | Rolling second. More gets rate_limited. |
| Hello interval | 250 ms | Repeated, with the same message id, until init arrives (only inside an iframe), and sent once more on the window's load event. |
| Ready budget | 3 s target, 10 s limit | Platform rule R-SDK-02 asks for ready within 3 s of the iframe starting to load; after 10 s the portal shows a retry screen and reloads the frame. |
| Standalone delay | 2 s | Outside an iframe only: after this the bridge switches to standalone mode. Inside an iframe the game never falls back. |
| Ack timeout | 15 s | The bridge treats a request without an ack after this time as a network failure. |
| Save depth | 64 levels | A deeper save is invalid. Saves must be plain JSON. |
| Game id | ^[a-z0-9][a-z0-9-]{0,63}$ | Lower-case letters, digits and hyphens, at most 64 characters. |
| Event and mode names | ^[a-z][a-z0-9_]{0,63}$ | snake_case, at most 64 characters (also event property names). |
| Portal origins meta tag | <meta name="gameportal:portal-origins"> | games-host injects the allowed portal origins into every game HTML page; the bridge reads it. |
A message that exceeds a limit is not executed: the portal answers with an ack that carries rate_limited or too_large.
Messages at a glance #
| Message | From | Sent over | Answered by | When |
|---|---|---|---|---|
hello | game | window message | init (or an ack with unsupported_version) | The game loaded inside an iframe. Repeated every 250 ms until init arrives, and once more (same id) after the window's load event, even after init: the portal closes a frame whose document finished loading without a hello after that load (navigation watchdog). |
init | portal | window message | none | Answers the first valid hello; carries the MessageChannel port. Can be sent again over the port (new session, after a session renewal). |
ready | game | port | none | The first screen is drawn and accepts input. Once per init. |
save | game | port | ack | The save changed (paced to one per 30 s) and immediately on pause or a hidden tab. |
score | game | port | ack | A score worth ranking was reached, normally at the end of a round. At most one per second. |
event | game | port | ack | Something meaningful for badges and daily challenges happened. At most 5 per second. |
pauseRequest | game | port | none | The player pressed Esc or P inside the game. The bridge sends it by itself; the portal then pauses the game with pause { reason: "overlay" }. |
session | game | port | none | The player starts or stops really playing. Only on a change. |
pause | portal | port | none | The tab is hidden, or the portal shows something over the game. |
resume | portal | port | none | The pause ended. Carries the current server time. |
lang | portal | port | none | The player changed the portal language. |
day | portal | port | none | The session crossed the player's local midnight. |
ack | portal | port | none | Answers every save, score and event. |
adBreak | game | port | adBreakResult | Optional (only when init.features.adBreaks is true): a natural break where an ad may run (start, pause, next, browse) or a rewarded offer (reward). The portal decides and may answer unavailable; never block the game on it. |
adReward | game | port | none | The player's answer to an adOffer: watch the rewarded ad (show: true) or not. |
adOffer | portal | port | adReward (no answer within 60 s counts as no) | A rewarded ad is ready for the adBreak named in for; ask the player. |
adBreakResult | portal | port | none | How the adBreak ended: shown, dismissed, viewed (grant the in-game reward only now) or unavailable. The portal grants nothing itself. |
mp_request | game | port | ack, then mp_start or mp_cancel | Optional (only when init.features.multiplayer is true): the player chose "play a friend" (invite) or "quick match" (quick). The portal opens its own lobby over the game. At most one per 2 s. |
mp_move | game | port | ack (conflict: stale seq or not your turn; invalid: illegal move) | The player's move in an online match, built on the latest mp_state seq. The server referees it; the board changes only when the next mp_state arrives. At most 4 per second, 1 KB of JSON. |
mp_emote | game | port | ack | A preset reaction (there is no free text). One per 3 s. The opponent's game receives an mp_emote from the portal with the sender's seat. |
mp_resign | game | port | ack | The player gives up the online match; the final mp_state follows with reason resign. |
mp_start | portal | port | none | A match was found, or resumed after a reload (resumed: true). An mp_state follows at once. |
mp_state | portal | port | none | The server's state of the match after every move (yours, the opponent's or the server's auto-move on a timeout), and at the end. Always draw this, never a locally predicted board. |
mp_cancel | portal | port | none | The lobby closed without a match. fallback_ai: the player chose to play the computer instead. |
The first two messages, hello and init, travel with window.postMessage. init brings a MessageChannel port with it, and everything after that, in both directions, goes over the port. The game never listens to window messages once the port exists.
game (iframe, games origin) portal (portal origin)
start() hello ------- postMessage(msg, portalOrigin) -------> checks event.source and event.origin
(again every 250 ms until init arrives, and once more after the window's load)
<------------ init + MessageChannel port ----------- language, guest flag, save, server time, challenge, limits
ready ready --------------- port -----------------------> hides its loading screen
playing session, save, score, event ---- port -----------> ack for save, score and event
<---------- pause, resume, lang, day, ack ---------hello message #
Game to portal. The first message of every session, sent only when the page runs inside an iframe (window.parent !== window).
Direction: game to portal. Sent over: window.postMessage with an exact target origin. Answered by: init (or an ack with unsupported_version).
When: The game loaded inside an iframe. Repeated every 250 ms until init arrives, and once more (same id) after the window's load event, even after init: the portal closes a frame whose document finished loading without a hello after that load (navigation watchdog).
| Field | Type | Required | Rules | Meaning |
|---|---|---|---|---|
gameId | string | yes | matches ^[a-z0-9][a-z0-9-]{0,63}$ | The manifest id. The portal checks it against the game it loaded in the frame. |
gameVersion | string | yes | 1 to 32 characters; matches ^[0-9A-Za-z.+-]+$ | The manifest version of this build. |
sdkVersions | array of integer | yes | 1 to 16 items; each at least 1; each at most 1000 | The SDK major versions this game speaks. The bridge sends [1]. |
The portal validates this payload strictly: unknown fields make the message invalid.
{
"sdk": "portal-sdk",
"v": 1,
"type": "hello",
"id": "3f2b8c1a-5d4e-4f6a-8b7c-9d0e1f2a3b4c",
"payload": {
"gameId": "my-game",
"gameVersion": "1.0.0",
"sdkVersions": [1]
}
}The portal accepts a hello only when event.source is the frame it created and event.origin is the origin it expected for that game. The game sends it once to each allowed portal origin, with an exact target origin and never "*". If the game reloads, the new page sends a new hello with a new id; the portal closes the old port and sends a new init.
init message #
Portal to game, with one MessageChannel port transferred. The game accepts it only from window.parent at one of the allowed portal origins.
Direction: portal to game. Sent over: window.postMessage with an exact target origin. Answered by: nothing (no ack).
When: Answers the first valid hello; carries the MessageChannel port. Can be sent again over the port (new session, after a session renewal).
| Field | Type | Required | Rules | Meaning |
|---|---|---|---|---|
sdkVersion | 1 | yes | The SDK major version of this session. | |
lang | "tr" or "en" | yes | The player's language. Change it when lang messages arrive; never ask the player. | |
player | object | yes | All a game ever learns about the player. | |
player.guest | boolean | yes | true for a guest, false for a signed-in player. No id, name or e-mail is sent, ever. | |
save | object or null | yes | The player's cloud save for this game, or null for a new player. | |
save.data | any JSON value | yes | Exactly what the game sent in its last save. Treat it as untrusted: parse it defensively. | |
save.revision | integer | yes | at least 0 | The server's revision of this save. The bridge sends it back as baseRevision. |
save.savedAt | ISO 8601 time | yes | When the server stored this save (server time). | |
save.saveVersion | integer | yes | at least 0 | The format version the save was stored with. Higher than your build's saveVersion means a newer build wrote it. |
time | object | yes | The server's idea of now. The device clock is never used (ADR-005). | |
time.serverTime | ISO 8601 time | yes | The server time at the moment init was built. | |
time.day | date | yes | YYYY-MM-DD | The player's local calendar day, decided by the server. Seed daily content with it. |
time.nextResetAt | ISO 8601 time | yes | When the player's day ends (their local midnight, as the server computes it). | |
challenge | object or null | yes | Today's daily challenge for this game and player, or null when there is none. | |
challenge.metric | string | yes | What is counted: a score mode or an event of the manifest. | |
challenge.target | number | yes | The target picked for this player's stage (one of the manifest's easy, medium, hard). | |
challenge.progress | number | yes | Today's progress as the portal counted it. | |
challenge.endsAt | ISO 8601 time | yes | When the challenge ends (the player's local midnight). | |
limits | object | yes | The limits this portal enforces. The bridge reads them; games normally need not. | |
limits.maxSaveBytes | integer | yes | greater than 0 | Largest save, as the UTF-8 length of its JSON. |
limits.minSaveIntervalMs | integer | yes | at least 0 | Shortest time between two saves, in milliseconds. |
features | object | no | What this portal offers beyond SDK v1 (optional; an older portal leaves it out). Check it before using an optional feature. | |
features.adBreaks | boolean | no | true when the portal shows ad breaks for this game (ads on, the game's switch on). When false or absent, bridge.ads.adBreak() resolves unavailable at once. | |
features.multiplayer | boolean | no | true when the portal can host online matches for this game (the manifest declares multiplayer and the page has a lobby). Only then may the game send mp_* messages; otherwise bridge.multiplayer.available is false. |
Written by the portal. Games should ignore fields they do not know: later revisions of v1 may add some.
{
"sdk": "portal-sdk",
"v": 1,
"type": "init",
"id": "3f2b8c1a-5d4e-4f6a-8b7c-9d0e1f2a3b4c",
"payload": {
"sdkVersion": 1,
"lang": "en",
"player": {
"guest": true
},
"save": {
"data": {
"coins": 120,
"level": 3
},
"revision": 7,
"savedAt": "2026-10-17T20:41:12.000Z",
"saveVersion": 1
},
"time": {
"serverTime": "2026-10-17T22:58:00.000Z",
"day": "2026-10-18",
"nextResetAt": "2026-10-18T21:00:00.000Z"
},
"challenge": {
"metric": "round_score",
"target": 300,
"progress": 140,
"endsAt": "2026-10-18T21:00:00.000Z"
},
"limits": {
"maxSaveBytes": 262144,
"minSaveIntervalMs": 30000
}
}
}Nothing about the player reaches the game except player.guest: no id, no name, no e-mail, no session key. Everything in save.data is the game's own data from an earlier session and must be parsed as untrusted input.
ready message #
Game to portal, over the port. Send it once the first screen is drawn and input works. The portal hides its loading screen; it shows a retry screen and reloads the frame if ready has not arrived 10 seconds after the frame loaded. The platform rule asks for 3 seconds.
Direction: game to portal. Sent over: the MessageChannel port. Answered by: nothing (no ack).
When: The first screen is drawn and accepts input. Once per init.
| Field | Type | Required | Rules | Meaning |
|---|---|---|---|---|
saveVersion | integer | yes | at least 0 | The saveVersion the game writes (the manifest value). |
The portal validates this payload strictly: unknown fields make the message invalid.
{
"sdk": "portal-sdk",
"v": 1,
"type": "ready",
"id": "3f2b8c1a-5d4e-4f6a-8b7c-9d0e1f2a3b4c",
"payload": {
"saveVersion": 1
}
}save message #
Game to portal. The bridge builds this from storage.save(); you rarely write one yourself.
Direction: game to portal. Sent over: the MessageChannel port. Answered by: ack.
When: The save changed (paced to one per 30 s) and immediately on pause or a hidden tab.
| Field | Type | Required | Rules | Meaning |
|---|---|---|---|---|
data | any JSON value | yes | The whole save. Plain JSON only (no Map, Date, undefined, BigInt or cycles), at most 64 levels deep. | |
baseRevision | integer or null | yes | at least 0 | The revision this state was built on (from init.save.revision or the last successful save), or null when the game never had a save. |
saveVersion | integer | yes | at least 0 | The format version of data (the manifest saveVersion). |
The portal validates this payload strictly: unknown fields make the message invalid.
{
"sdk": "portal-sdk",
"v": 1,
"type": "save",
"id": "3f2b8c1a-5d4e-4f6a-8b7c-9d0e1f2a3b4c",
"payload": {
"data": {
"coins": 135,
"level": 3
},
"baseRevision": 7,
"saveVersion": 1
}
}The portal stores the save with an optimistic lock: baseRevision must equal the stored revision, otherwise the answer is conflict. A save from a lower saveVersion than the stored one is a conflict too. Every replaced save, and every save refused with a conflict, is kept in the save history.
score message #
Game to portal. A score is whatever number the game ranks players by, normally sent at the end of a round (not every frame).
Direction: game to portal. Sent over: the MessageChannel port. Answered by: ack.
When: A score worth ranking was reached, normally at the end of a round. At most one per second.
| Field | Type | Required | Rules | Meaning |
|---|---|---|---|---|
mode | string | yes | matches ^[a-z][a-z0-9_]{0,63}$ | A score mode declared in the manifest scores. An undeclared mode is answered with invalid. |
value | number | yes | The score. A finite number; the manifest rules for the mode decide whether it counts. |
The portal validates this payload strictly: unknown fields make the message invalid.
{
"sdk": "portal-sdk",
"v": 1,
"type": "score",
"id": "3f2b8c1a-5d4e-4f6a-8b7c-9d0e1f2a3b4c",
"payload": {
"mode": "round_score",
"value": 240
}
}An undeclared mode is refused with invalid. A declared mode is checked against its plausibility rules: a score that breaks them is stored as suspicious and never reaches a leaderboard, but the game still gets ok: true and is not told.
event message #
Game to portal. Events feed badges and the daily challenge.
Direction: game to portal. Sent over: the MessageChannel port. Answered by: ack.
When: Something meaningful for badges and daily challenges happened. At most 5 per second.
| Field | Type | Required | Rules | Meaning |
|---|---|---|---|---|
name | string | yes | matches ^[a-z][a-z0-9_]{0,63}$ | An event declared in the manifest events. An undeclared event is answered with invalid. |
props | object | no | keys matches ^[a-z][a-z0-9_]{0,63}$ | Extra data. Every key must be declared for this event in the manifest, with the declared type. |
props.<prop> | string or number or boolean | per entry | string: at most 256 characters | A string (at most 256 characters), a number or a boolean, matching the manifest declaration. |
The portal validates this payload strictly: unknown fields make the message invalid.
{
"sdk": "portal-sdk",
"v": 1,
"type": "event",
"id": "3f2b8c1a-5d4e-4f6a-8b7c-9d0e1f2a3b4c",
"payload": {
"name": "round_end",
"props": {
"score": 240,
"hits": 18
}
}
}The event must be declared in the manifest, and every property too, with the declared type; otherwise the answer is invalid.
session message #
Game to portal. It says whether the player is really playing right now. The portal sends its own heartbeats and counts active play time from them, so a game sends session only when activity changes (a round starts, a round ends, the game is paused), never as a heartbeat.
Direction: game to portal. Sent over: the MessageChannel port. Answered by: nothing (no ack).
When: The player starts or stops really playing. Only on a change.
| Field | Type | Required | Rules | Meaning |
|---|---|---|---|---|
active | boolean | yes | true while the player is really playing, false when they stop or the game is paused. Not a heartbeat: send it only on a change. |
The portal validates this payload strictly: unknown fields make the message invalid.
{
"sdk": "portal-sdk",
"v": 1,
"type": "session",
"id": "3f2b8c1a-5d4e-4f6a-8b7c-9d0e1f2a3b4c",
"payload": {
"active": true
}
}pauseRequest message #
Game to portal. The player pressed Esc or P inside the game. The bridge listens for both and sends this by itself, so a game built on the bridge does nothing; the portal then pauses the game the same way as its own Pause button (pause with reason: "overlay"). A game that speaks the SDK without the bridge may send it when its own pause key is pressed. The bridge sends nothing while the portal has the game paused, the portal ignores it before ready, while an ad break plays and when the game frame does not have the focus, and an older portal drops it with a warning only. P counts only without Shift, Ctrl, Alt or Meta, not on a held key's repeats and not while a text field has the focus (isPauseKey from @gameportal/game-bridge is the exact test). A game that needs P for play passes pauseOnKeyP: false to createBridge (the game-kit hosts take the same option) and keeps Esc only. Esc and P are reserved while embedded: do not bind your own pause or menus to them in portal mode (check host.embedded; game-kit's bindPauseKeys does it for a game's own pause outside the portal).
Direction: game to portal. Sent over: the MessageChannel port. Answered by: nothing (no ack).
When: The player pressed Esc or P inside the game. The bridge sends it by itself; the portal then pauses the game with pause { reason: "overlay" }.
The payload is an empty object.
The portal validates this payload strictly: unknown fields make the message invalid.
{
"sdk": "portal-sdk",
"v": 1,
"type": "pauseRequest",
"id": "3f2b8c1a-5d4e-4f6a-8b7c-9d0e1f2a3b4c",
"payload": {}
}pause message #
Portal to game. Freeze game time, animation and audio, say session(false), and wait for resume.
Direction: portal to game. Sent over: the MessageChannel port. Answered by: nothing (no ack).
When: The tab is hidden, or the portal shows something over the game.
| Field | Type | Required | Rules | Meaning |
|---|---|---|---|---|
reason | "hidden" or "overlay" or "ad" | yes | Why: the tab is hidden, the portal shows an overlay (menu, sign-in), or an ad is playing. |
Written by the portal. Games should ignore fields they do not know: later revisions of v1 may add some.
{
"sdk": "portal-sdk",
"v": 1,
"type": "pause",
"id": "3f2b8c1a-5d4e-4f6a-8b7c-9d0e1f2a3b4c",
"payload": {
"reason": "overlay"
}
}A hidden tab is a pause too, but the browser tells the game that itself (visibilitychange); handle both as one "suspended" signal. The bridge flushes the pending save on pause.
resume message #
Portal to game. Continue. In the middle of a round, wait for a tap before the round goes on. Time that passed while paused is measured with serverTime, never with the device clock.
Direction: portal to game. Sent over: the MessageChannel port. Answered by: nothing (no ack).
When: The pause ended. Carries the current server time.
| Field | Type | Required | Rules | Meaning |
|---|---|---|---|---|
serverTime | ISO 8601 time | yes | The server time now. Use it, not the device clock, for time that passed while paused. |
Written by the portal. Games should ignore fields they do not know: later revisions of v1 may add some.
{
"sdk": "portal-sdk",
"v": 1,
"type": "resume",
"id": "3f2b8c1a-5d4e-4f6a-8b7c-9d0e1f2a3b4c",
"payload": {
"serverTime": "2026-10-17T23:02:30.000Z"
}
}lang message #
Portal to game. Switch the language without reloading. In the portal the game hides its own language button: the portal owns the language.
Direction: portal to game. Sent over: the MessageChannel port. Answered by: nothing (no ack).
When: The player changed the portal language.
| Field | Type | Required | Rules | Meaning |
|---|---|---|---|---|
lang | "tr" or "en" | yes | The new language. Switch without reloading. |
Written by the portal. Games should ignore fields they do not know: later revisions of v1 may add some.
{
"sdk": "portal-sdk",
"v": 1,
"type": "lang",
"id": "3f2b8c1a-5d4e-4f6a-8b7c-9d0e1f2a3b4c",
"payload": {
"lang": "tr"
}
}day message #
Portal to game. The player's local day changed while the game was open. Restart daily counters and daily content here, and nowhere else: not at local midnight, not when the device clock says so.
Direction: portal to game. Sent over: the MessageChannel port. Answered by: nothing (no ack).
When: The session crossed the player's local midnight.
| Field | Type | Required | Rules | Meaning |
|---|---|---|---|---|
day | date | yes | YYYY-MM-DD | The player's new local calendar day. |
nextResetAt | ISO 8601 time | yes | When the new day ends. | |
challenge | object or null | yes | Today's daily challenge, or null. | |
challenge.metric | string | yes | What is counted: a score mode or an event of the manifest. | |
challenge.target | number | yes | The target picked for this player's stage (one of the manifest's easy, medium, hard). | |
challenge.progress | number | yes | Today's progress as the portal counted it. | |
challenge.endsAt | ISO 8601 time | yes | When the challenge ends (the player's local midnight). |
Written by the portal. Games should ignore fields they do not know: later revisions of v1 may add some.
{
"sdk": "portal-sdk",
"v": 1,
"type": "day",
"id": "3f2b8c1a-5d4e-4f6a-8b7c-9d0e1f2a3b4c",
"payload": {
"day": "2026-10-19",
"nextResetAt": "2026-10-19T21:00:00.000Z",
"challenge": {
"metric": "round_score",
"target": 300,
"progress": 0,
"endsAt": "2026-10-19T21:00:00.000Z"
}
}
}ack message #
Portal to game. The answer to every save, score and event, and the unsupported_version answer to a hello. It refers to the message it answers with payload.for.
Direction: portal to game. Sent over: the MessageChannel port. Answered by: nothing (no ack).
When: Answers every save, score and event.
| Field | Type | Required | Rules | Meaning |
|---|---|---|---|---|
for | UUID | yes | The id of the save, score or event message (or of the hello) this answers. | |
ok | boolean | yes | true when the portal accepted the message. | |
error | "too_large" or "rate_limited" or "conflict" or "invalid" or "network" or "unauthorized" or "unsupported_version" | no | Why it was refused. See the error codes above for what the game does. | |
revision | integer | no | at least 0 | After a successful save: the new revision of the stored save. |
conflict | object | no | With error: "conflict": the save the server holds now. Show it to the player or merge it. | |
conflict.revision | integer | yes | at least 0 | The revision of the server's save. |
conflict.data | any JSON value | yes | The server's save. | |
conflict.savedAt | ISO 8601 time | yes | When the server stored it. | |
conflict.saveVersion | integer | no | at least 0 | The format version of the server's save. Higher than yours: ask the player to reload instead of choosing. |
Written by the portal. Games should ignore fields they do not know: later revisions of v1 may add some.
{
"sdk": "portal-sdk",
"v": 1,
"type": "ack",
"id": "7a9e2d04-1b6f-4c3d-9e8a-5f4b3c2d1e0f",
"payload": {
"for": "3f2b8c1a-5d4e-4f6a-8b7c-9d0e1f2a3b4c",
"ok": false,
"error": "conflict",
"conflict": {
"revision": 9,
"data": {
"coins": 410,
"level": 4
},
"savedAt": "2026-10-17T22:55:00.000Z",
"saveVersion": 1
}
}
}Optional ad messages (adBreak, adOffer, adReward, adBreakResult) #
Optional and backwards compatible: send them only when init.features.adBreaks is true, which means the portal shows ads for this game. Ads run on the portal page (H5 Games Ads), never inside the game. A game never blocks on an ad: with the bridge, bridge.ads.adBreak({ type, name, onOffer }) always resolves, with unavailable when there is no ad. A rewarded ad grants nothing on the portal side (no XP); the game may give an in-game cosmetic or a continue only after viewed.
adBreak message #
Direction: game to portal. Sent over: the MessageChannel port. Answered by: adBreakResult.
When: Optional (only when init.features.adBreaks is true): a natural break where an ad may run (start, pause, next, browse) or a rewarded offer (reward). The portal decides and may answer unavailable; never block the game on it.
| Field | Type | Required | Rules | Meaning |
|---|---|---|---|---|
type | "start" or "pause" or "next" or "browse" or "reward" | yes | start, pause, next or browse for a natural break; reward for a rewarded offer. | |
name | string | yes | matches ^[a-z][a-z0-9_]{0,63}$ | A short name for this break place (letters, digits, _), e.g. level_end. |
The portal validates this payload strictly: unknown fields make the message invalid.
{
"sdk": "portal-sdk",
"v": 1,
"type": "adBreak",
"id": "3f2b8c1a-5d4e-4f6a-8b7c-9d0e1f2a3b4c",
"payload": {
"type": "next",
"name": "level_end"
}
}adOffer message #
Direction: portal to game. Sent over: the MessageChannel port. Answered by: adReward (no answer within 60 s counts as no).
When: A rewarded ad is ready for the adBreak named in for; ask the player.
| Field | Type | Required | Rules | Meaning |
|---|---|---|---|---|
for | UUID | yes | The id of the adBreak message this offer belongs to. |
Written by the portal. Games should ignore fields they do not know: later revisions of v1 may add some.
{
"sdk": "portal-sdk",
"v": 1,
"type": "adOffer",
"id": "3f2b8c1a-5d4e-4f6a-8b7c-9d0e1f2a3b4c",
"payload": {
"for": "3f2b8c1a-5d4e-4f6a-8b7c-9d0e1f2a3b4c"
}
}adReward message #
Direction: game to portal. Sent over: the MessageChannel port. Answered by: nothing (no ack).
When: The player's answer to an adOffer: watch the rewarded ad (show: true) or not.
| Field | Type | Required | Rules | Meaning |
|---|---|---|---|---|
for | UUID | yes | The id of the adOffer's adBreak. | |
show | boolean | yes | true: the player wants to watch the rewarded ad; false: not now. |
The portal validates this payload strictly: unknown fields make the message invalid.
{
"sdk": "portal-sdk",
"v": 1,
"type": "adReward",
"id": "3f2b8c1a-5d4e-4f6a-8b7c-9d0e1f2a3b4c",
"payload": {
"for": "3f2b8c1a-5d4e-4f6a-8b7c-9d0e1f2a3b4c",
"show": true
}
}adBreakResult message #
Direction: portal to game. Sent over: the MessageChannel port. Answered by: nothing (no ack).
When: How the adBreak ended: shown, dismissed, viewed (grant the in-game reward only now) or unavailable. The portal grants nothing itself.
| Field | Type | Required | Rules | Meaning |
|---|---|---|---|---|
for | UUID | yes | The id of the adBreak message this answers. | |
status | "shown" or "dismissed" or "viewed" or "unavailable" | yes | shown, dismissed, viewed (rewarded ad watched to the end) or unavailable. |
Written by the portal. Games should ignore fields they do not know: later revisions of v1 may add some.
{
"sdk": "portal-sdk",
"v": 1,
"type": "adBreakResult",
"id": "3f2b8c1a-5d4e-4f6a-8b7c-9d0e1f2a3b4c",
"payload": {
"for": "3f2b8c1a-5d4e-4f6a-8b7c-9d0e1f2a3b4c",
"status": "viewed"
}
}Online matches (multiplayer addendum) #
Optional. A game whose manifest declares multiplayer and whose portal sets init.features.multiplayer may send these. The server referees every move (ADR-008); the game only draws what mp_state says.
mp_request message #
Direction: game to portal. Sent over: the MessageChannel port. Answered by: ack, then mp_start or mp_cancel.
When: Optional (only when init.features.multiplayer is true): the player chose "play a friend" (invite) or "quick match" (quick). The portal opens its own lobby over the game. At most one per 2 s.
| Field | Type | Required | Rules | Meaning |
|---|---|---|---|---|
mode | "invite" or "quick" | yes | invite: a link the player sends to a friend; quick: the next player waiting for this game. |
The portal validates this payload strictly: unknown fields make the message invalid.
{
"sdk": "portal-sdk",
"v": 1,
"type": "mp_request",
"id": "3f2b8c1a-5d4e-4f6a-8b7c-9d0e1f2a3b4c",
"payload": {
"mode": "invite"
}
}mp_move message #
Direction: game to portal. Sent over: the MessageChannel port. Answered by: ack (conflict: stale seq or not your turn; invalid: illegal move).
When: The player's move in an online match, built on the latest mp_state seq. The server referees it; the board changes only when the next mp_state arrives. At most 4 per second, 1 KB of JSON.
| Field | Type | Required | Rules | Meaning |
|---|---|---|---|---|
matchId | UUID | yes | The match, a server UUID from mp_start. | |
seq | integer | yes | at least 0 | The seq of the mp_state this move was made on. A stale one is answered conflict. |
move | any JSON value | yes | The game's own move as small JSON (Mangala: { "pit": 2 }). The server's rules engine checks it. |
The portal validates this payload strictly: unknown fields make the message invalid.
{
"sdk": "portal-sdk",
"v": 1,
"type": "mp_move",
"id": "3f2b8c1a-5d4e-4f6a-8b7c-9d0e1f2a3b4c",
"payload": {
"matchId": "5c1d7e9a-2b3f-4a6c-8d0e-1f2a3b4c5d6e",
"seq": 4,
"move": {
"pit": 2
}
}
}mp_emote message #
Direction: game to portal. Sent over: the MessageChannel port. Answered by: ack.
When: A preset reaction (there is no free text). One per 3 s. The opponent's game receives an mp_emote from the portal with the sender's seat.
| Field | Type | Required | Rules | Meaning |
|---|---|---|---|---|
matchId | UUID | yes | The match, a server UUID from mp_start. | |
emote | "hello" or "good_luck" or "well_played" or "nice_move" or "thanks" or "oops" or "thinking" or "gg" | yes | One of the preset reactions: hello, good_luck, well_played, nice_move, thanks, oops, thinking, gg. |
The portal validates this payload strictly: unknown fields make the message invalid.
{
"sdk": "portal-sdk",
"v": 1,
"type": "mp_emote",
"id": "3f2b8c1a-5d4e-4f6a-8b7c-9d0e1f2a3b4c",
"payload": {
"matchId": "5c1d7e9a-2b3f-4a6c-8d0e-1f2a3b4c5d6e",
"emote": "well_played"
}
}mp_resign message #
Direction: game to portal. Sent over: the MessageChannel port. Answered by: ack.
When: The player gives up the online match; the final mp_state follows with reason resign.
| Field | Type | Required | Rules | Meaning |
|---|---|---|---|---|
matchId | UUID | yes | The match, a server UUID from mp_start. |
The portal validates this payload strictly: unknown fields make the message invalid.
{
"sdk": "portal-sdk",
"v": 1,
"type": "mp_resign",
"id": "3f2b8c1a-5d4e-4f6a-8b7c-9d0e1f2a3b4c",
"payload": {
"matchId": "5c1d7e9a-2b3f-4a6c-8d0e-1f2a3b4c5d6e"
}
}mp_start message #
Direction: portal to game. Sent over: the MessageChannel port. Answered by: nothing (no ack).
When: A match was found, or resumed after a reload (resumed: true). An mp_state follows at once.
| Field | Type | Required | Rules | Meaning |
|---|---|---|---|---|
matchId | UUID | yes | The match, a server UUID from mp_start. | |
mode | "invite" or "quick" | yes | How the match was made: invite or quick. | |
resumed | boolean | yes | true when an earlier match goes on (the page was reloaded). | |
turnSeconds | integer | yes | at least 10; at most 120 | Seconds per move on the server's clock (the manifest's multiplayer.turnSeconds). |
you | object | yes | Your seat in this match. | |
you.seat | integer | yes | at least 0; at most 7 | The seat number (0 or 1). |
you.seatId | string | yes | matches ^[0-9a-f]{16}$ | An opaque id for this seat in this match only. Never a user id. |
you.handle | string | yes | matches ^[0-9A-F]{8}$ | The public pseudonym (8 hex characters, as on the leaderboards). Show it as the player's name. |
you.guest | boolean | yes | true when that player is a guest. | |
opponents | array of object | yes | 1 to 7 items | The other seats. |
opponents[] | object | yes | One opponent. | |
opponents[].seat | integer | yes | at least 0; at most 7 | The seat number (0 or 1). |
opponents[].seatId | string | yes | matches ^[0-9a-f]{16}$ | An opaque id for this seat in this match only. Never a user id. |
opponents[].handle | string | yes | matches ^[0-9A-F]{8}$ | The public pseudonym (8 hex characters, as on the leaderboards). Show it as the player's name. |
opponents[].guest | boolean | yes | true when that player is a guest. |
Written by the portal. Games should ignore fields they do not know: later revisions of v1 may add some.
{
"sdk": "portal-sdk",
"v": 1,
"type": "mp_start",
"id": "3f2b8c1a-5d4e-4f6a-8b7c-9d0e1f2a3b4c",
"payload": {
"matchId": "5c1d7e9a-2b3f-4a6c-8d0e-1f2a3b4c5d6e",
"mode": "invite",
"resumed": false,
"turnSeconds": 30,
"you": {
"seat": 0,
"seatId": "0123456789abcdef",
"handle": "1A2B3C4D",
"guest": true
},
"opponents": [
{
"seat": 1,
"seatId": "fedcba9876543210",
"handle": "9F8E7D6C",
"guest": false
}
]
}
}mp_state message #
Direction: portal to game. Sent over: the MessageChannel port. Answered by: nothing (no ack).
When: The server's state of the match after every move (yours, the opponent's or the server's auto-move on a timeout), and at the end. Always draw this, never a locally predicted board.
| Field | Type | Required | Rules | Meaning |
|---|---|---|---|---|
matchId | UUID | yes | The match, a server UUID from mp_start. | |
seq | integer | yes | at least 0 | Moves applied so far. Send it back with your next mp_move. |
status | "active" or "finished" | yes | active or finished. | |
toMove | integer or null | yes | at least 0; at most 7 | The seat to move, or null when the match is over. |
deadlineAt | ISO 8601 time or null | yes | When the seat to move runs out of time (server time), or null. | |
serverTime | ISO 8601 time | yes | The server's now: count the move clock from it, never from the device clock (ADR-005). | |
state | any JSON value | yes | The game's state as the server's rules engine keeps it. Parse it defensively. | |
lastMove | object or null | yes | The move that led here, or null at the start. | |
lastMove.seat | integer | yes | at least 0; at most 7 | The seat that moved. |
lastMove.move | any JSON value | yes | The move as the game sent it. | |
lastMove.auto | boolean | yes | true when the server played it because the seat ran out of time. | |
result | object or null | yes | null while the match runs. | |
result.winner | integer or null | yes | at least 0; at most 7 | The winning seat, or null for a draw or an abandoned match. |
result.reason | "normal" or "resign" or "timeout" or "abandoned" | yes | normal, resign, timeout or abandoned. | |
connected | array of boolean | yes | at most 8 items | Per seat: whether that player is connected right now. |
Written by the portal. Games should ignore fields they do not know: later revisions of v1 may add some.
{
"sdk": "portal-sdk",
"v": 1,
"type": "mp_state",
"id": "3f2b8c1a-5d4e-4f6a-8b7c-9d0e1f2a3b4c",
"payload": {
"matchId": "5c1d7e9a-2b3f-4a6c-8d0e-1f2a3b4c5d6e",
"seq": 5,
"status": "active",
"toMove": 1,
"deadlineAt": "2026-10-19T18:00:30.000Z",
"serverTime": "2026-10-19T18:00:00.000Z",
"state": {
"board": [4, 4, 1, 5, 5, 5, 1, 4, 4, 4, 4, 4, 4, 0],
"toMove": 1,
"over": false,
"moves": 1,
"last": null
},
"lastMove": {
"seat": 0,
"move": {
"pit": 2
},
"auto": false
},
"result": null,
"connected": [true, true]
}
}mp_cancel message #
Direction: portal to game. Sent over: the MessageChannel port. Answered by: nothing (no ack).
When: The lobby closed without a match. fallback_ai: the player chose to play the computer instead.
| Field | Type | Required | Rules | Meaning |
|---|---|---|---|---|
reason | "cancelled" or "expired" or "invite_invalid" or "fallback_ai" or "error" | yes | cancelled, expired, invite_invalid, fallback_ai or error. |
Written by the portal. Games should ignore fields they do not know: later revisions of v1 may add some.
{
"sdk": "portal-sdk",
"v": 1,
"type": "mp_cancel",
"id": "3f2b8c1a-5d4e-4f6a-8b7c-9d0e1f2a3b4c",
"payload": {
"reason": "fallback_ai"
}
}Error codes #
| Code | Meaning | What the game does |
|---|---|---|
too_large | The save is larger than 256 KB (UTF-8 length of its JSON). | Shrink the save (derive what you can instead of storing it). The state is not retried; keep your local copy. |
rate_limited | A rate limit was exceeded (saves, scores or events). | Try again at the next allowed time. The bridge does this for saves; space your own scores and events. |
conflict | The server has a newer save (another tab or device), or one written in a newer saveVersion that an older build must not overwrite. | Show the player the save in conflict and let them choose, or merge both without loss. If conflict.saveVersion is higher than yours, do not ask: tell the player to reload the page. |
invalid | The message does not match the schema or the manifest (undeclared event or score mode, a property of the wrong type, a save that is not plain JSON). | Log it for yourself and show the player nothing. Never retry the same message. |
network | The portal could not reach its server. The bridge also reports network itself when no ack arrives within 15 s. | Keep the save queued and send it when the connection is back (the bridge retries with backoff and on online). |
unauthorized | The player's session expired. | Nothing to do: the portal renews the session and sends init again over the port; the bridge then retries. |
unsupported_version | The game's sdkVersions are not supported by this portal. Answers the hello. | The game cannot use portal mode: stay on a calm screen. The bridge reports mode unsupported; the portal shows its own error screen. |
The game decides what to do with each code; the platform rules (R-SDK-11) check that it never loops on one, never shows the player a raw error and never loses a save to one.
Utilities in @gameportal/sdk-types #
| Export | Use |
|---|---|
measureJson(value, maxBytes) | Checks that a value is plain JSON and measures it the way the portal does (UTF-8 length of the compact JSON). Use it in a unit test to keep your save well under 256 KB after long play. |
gameManifestSchema | The zod schema of manifest.json. Parse your manifest with it in a test. |
isEventAllowed(manifest, event), isScoreModeAllowed(manifest, score) | What the portal does with an event or a score mode. Use them to prove your code sends only what the manifest declares. |
helloSchema, initSchema, saveSchema, ... ackSchema | Every message as a zod schema, for tests and for engines that implement the protocol themselves. |
makeEnvelope(type, payload) | Builds a valid envelope with a fresh UUID. |
MAX_SAVE_BYTES, MIN_SAVE_INTERVAL_MS, SCORE_MIN_INTERVAL_MS, EVENT_MAX_PER_SECOND, ... | The limits as constants. |
import { MAX_SAVE_BYTES, gameManifestSchema, measureJson } from "@gameportal/sdk-types";
export function checkSave(save: unknown, manifest: unknown): void {
const size = measureJson(save, MAX_SAVE_BYTES);
if (!size.ok) throw new Error(`the save is ${size.error}`); // "invalid" (not plain JSON) or "too_large"
console.log(`the save is ${size.bytes} bytes of ${MAX_SAVE_BYTES}`);
gameManifestSchema.parse(manifest); // throws a readable error when a field is wrong
}If your engine cannot use the bridge, implement the protocol above exactly and run pnpm check: the runner holds every game, bridge or not, to the same rules (R-SDK-01 only asks you to prefer the bridge).