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 #

PackageWhat it isYou use it for
@gameportal/game-bridgeThe 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-typesMessage 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-kitOptional, 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-toolscreate:game, the dev console, validate and check.Your daily workflow, see Testing.
@gameportal/rulesThe 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.

ts
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) #

OptionTypeMeaning
gameIdstringThe manifest id. Sent in hello; also the prefix of the standalone storage key.
gameVersionstringThe manifest version. Sent in hello.
saveVersionnumberThe format version of your save (manifest saveVersion). Sent with ready and every save.
upgradeSave(data, fromVersion) => unknownOptional. 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.
portalOriginsstring[]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.
standaloneStorageKeystringOptional. The localStorage key in standalone mode. Defaults to <gameId>:save. Keep the <gameId>: prefix: every game shares one origin.
window, now, helloIntervalMs, standaloneDelayMs, ackTimeoutMs, loggerFor tests: inject a fake window and clock, or change the timings. Production code leaves them out.

Properties and methods #

MemberMeaning
bridge.modebooting (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.initThe 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.storageThe 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 #

EventValueFires when
modethe new modeThe 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.
savea save statusSomething happened to a save, see below.

The save event carries one of these statuses:

statusExtra fieldsMeaningWhat the game does
savedrevisionThe portal stored the save.Nothing.
queuedreason: rate_limited, network, unauthorized or waiting_for_portalThe save is waiting and will be sent.Nothing; at most show a quiet indicator.
conflictconflictThe 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.
outdatedsaveVersionThe 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.
errorerrortoo_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.

MemberMeaning
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, outdatedRead-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.

ts
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:

ts
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:

ts
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 }.

FieldValueMeaning
sdk"portal-sdk"Identifies the protocol.
v1SDK major version of the message.
typestringThe message type: hello, init, ready, save, ...
idUUIDUnique per message. An ack refers to it in payload.for. The bridge repeats the same hello id on every retransmission.
payloadobjectDepends on type; see the messages below.

Limits #

LimitValueNotes
Save size256 KB (262,144 bytes)UTF-8 length of the compact JSON of save.data. Larger saves get too_large.
Save interval1 per 30 sPlus one immediate save when pause arrives or the tab is hidden. The bridge paces this for you.
Score interval1 per 1 sMore often gets rate_limited.
Event rate5 per secondRolling second. More gets rate_limited.
Hello interval250 msRepeated, with the same message id, until init arrives (only inside an iframe), and sent once more on the window's load event.
Ready budget3 s target, 10 s limitPlatform 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 delay2 sOutside an iframe only: after this the bridge switches to standalone mode. Inside an iframe the game never falls back.
Ack timeout15 sThe bridge treats a request without an ack after this time as a network failure.
Save depth64 levelsA 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 #

MessageFromSent overAnswered byWhen
hellogamewindow messageinit (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).
initportalwindow messagenoneAnswers the first valid hello; carries the MessageChannel port. Can be sent again over the port (new session, after a session renewal).
readygameportnoneThe first screen is drawn and accepts input. Once per init.
savegameportackThe save changed (paced to one per 30 s) and immediately on pause or a hidden tab.
scoregameportackA score worth ranking was reached, normally at the end of a round. At most one per second.
eventgameportackSomething meaningful for badges and daily challenges happened. At most 5 per second.
pauseRequestgameportnoneThe player pressed Esc or P inside the game. The bridge sends it by itself; the portal then pauses the game with pause { reason: "overlay" }.
sessiongameportnoneThe player starts or stops really playing. Only on a change.
pauseportalportnoneThe tab is hidden, or the portal shows something over the game.
resumeportalportnoneThe pause ended. Carries the current server time.
langportalportnoneThe player changed the portal language.
dayportalportnoneThe session crossed the player's local midnight.
ackportalportnoneAnswers every save, score and event.
adBreakgameportadBreakResultOptional (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.
adRewardgameportnoneThe player's answer to an adOffer: watch the rewarded ad (show: true) or not.
adOfferportalportadReward (no answer within 60 s counts as no)A rewarded ad is ready for the adBreak named in for; ask the player.
adBreakResultportalportnoneHow the adBreak ended: shown, dismissed, viewed (grant the in-game reward only now) or unavailable. The portal grants nothing itself.
mp_requestgameportack, then mp_start or mp_cancelOptional (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_movegameportack (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_emotegameportackA 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_resigngameportackThe player gives up the online match; the final mp_state follows with reason resign.
mp_startportalportnoneA match was found, or resumed after a reload (resumed: true). An mp_state follows at once.
mp_stateportalportnoneThe 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_cancelportalportnoneThe 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).

FieldTypeRequiredRulesMeaning
gameIdstringyesmatches ^[a-z0-9][a-z0-9-]{0,63}$The manifest id. The portal checks it against the game it loaded in the frame.
gameVersionstringyes1 to 32 characters; matches ^[0-9A-Za-z.+-]+$The manifest version of this build.
sdkVersionsarray of integeryes1 to 16 items; each at least 1; each at most 1000The SDK major versions this game speaks. The bridge sends [1].

The portal validates this payload strictly: unknown fields make the message invalid.

json
{
  "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).

FieldTypeRequiredRulesMeaning
sdkVersion1yesThe SDK major version of this session.
lang"tr" or "en"yesThe player's language. Change it when lang messages arrive; never ask the player.
playerobjectyesAll a game ever learns about the player.
player.guestbooleanyestrue for a guest, false for a signed-in player. No id, name or e-mail is sent, ever.
saveobject or nullyesThe player's cloud save for this game, or null for a new player.
save.dataany JSON valueyesExactly what the game sent in its last save. Treat it as untrusted: parse it defensively.
save.revisionintegeryesat least 0The server's revision of this save. The bridge sends it back as baseRevision.
save.savedAtISO 8601 timeyesWhen the server stored this save (server time).
save.saveVersionintegeryesat least 0The format version the save was stored with. Higher than your build's saveVersion means a newer build wrote it.
timeobjectyesThe server's idea of now. The device clock is never used (ADR-005).
time.serverTimeISO 8601 timeyesThe server time at the moment init was built.
time.daydateyesYYYY-MM-DDThe player's local calendar day, decided by the server. Seed daily content with it.
time.nextResetAtISO 8601 timeyesWhen the player's day ends (their local midnight, as the server computes it).
challengeobject or nullyesToday's daily challenge for this game and player, or null when there is none.
challenge.metricstringyesWhat is counted: a score mode or an event of the manifest.
challenge.targetnumberyesThe target picked for this player's stage (one of the manifest's easy, medium, hard).
challenge.progressnumberyesToday's progress as the portal counted it.
challenge.endsAtISO 8601 timeyesWhen the challenge ends (the player's local midnight).
limitsobjectyesThe limits this portal enforces. The bridge reads them; games normally need not.
limits.maxSaveBytesintegeryesgreater than 0Largest save, as the UTF-8 length of its JSON.
limits.minSaveIntervalMsintegeryesat least 0Shortest time between two saves, in milliseconds.
featuresobjectnoWhat this portal offers beyond SDK v1 (optional; an older portal leaves it out). Check it before using an optional feature.
features.adBreaksbooleannotrue 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.multiplayerbooleannotrue 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.

json
{
  "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.

FieldTypeRequiredRulesMeaning
saveVersionintegeryesat least 0The saveVersion the game writes (the manifest value).

The portal validates this payload strictly: unknown fields make the message invalid.

json
{
  "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.

FieldTypeRequiredRulesMeaning
dataany JSON valueyesThe whole save. Plain JSON only (no Map, Date, undefined, BigInt or cycles), at most 64 levels deep.
baseRevisioninteger or nullyesat least 0The revision this state was built on (from init.save.revision or the last successful save), or null when the game never had a save.
saveVersionintegeryesat least 0The format version of data (the manifest saveVersion).

The portal validates this payload strictly: unknown fields make the message invalid.

json
{
  "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.

FieldTypeRequiredRulesMeaning
modestringyesmatches ^[a-z][a-z0-9_]{0,63}$A score mode declared in the manifest scores. An undeclared mode is answered with invalid.
valuenumberyesThe 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.

json
{
  "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.

FieldTypeRequiredRulesMeaning
namestringyesmatches ^[a-z][a-z0-9_]{0,63}$An event declared in the manifest events. An undeclared event is answered with invalid.
propsobjectnokeys 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 booleanper entrystring: at most 256 charactersA 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.

json
{
  "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.

FieldTypeRequiredRulesMeaning
activebooleanyestrue 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.

json
{
  "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.

json
{
  "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.

FieldTypeRequiredRulesMeaning
reason"hidden" or "overlay" or "ad"yesWhy: 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.

json
{
  "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.

FieldTypeRequiredRulesMeaning
serverTimeISO 8601 timeyesThe 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.

json
{
  "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.

FieldTypeRequiredRulesMeaning
lang"tr" or "en"yesThe new language. Switch without reloading.

Written by the portal. Games should ignore fields they do not know: later revisions of v1 may add some.

json
{
  "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.

FieldTypeRequiredRulesMeaning
daydateyesYYYY-MM-DDThe player's new local calendar day.
nextResetAtISO 8601 timeyesWhen the new day ends.
challengeobject or nullyesToday's daily challenge, or null.
challenge.metricstringyesWhat is counted: a score mode or an event of the manifest.
challenge.targetnumberyesThe target picked for this player's stage (one of the manifest's easy, medium, hard).
challenge.progressnumberyesToday's progress as the portal counted it.
challenge.endsAtISO 8601 timeyesWhen 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.

json
{
  "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.

FieldTypeRequiredRulesMeaning
forUUIDyesThe id of the save, score or event message (or of the hello) this answers.
okbooleanyestrue when the portal accepted the message.
error"too_large" or "rate_limited" or "conflict" or "invalid" or "network" or "unauthorized" or "unsupported_version"noWhy it was refused. See the error codes above for what the game does.
revisionintegernoat least 0After a successful save: the new revision of the stored save.
conflictobjectnoWith error: "conflict": the save the server holds now. Show it to the player or merge it.
conflict.revisionintegeryesat least 0The revision of the server's save.
conflict.dataany JSON valueyesThe server's save.
conflict.savedAtISO 8601 timeyesWhen the server stored it.
conflict.saveVersionintegernoat least 0The 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.

json
{
  "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.

FieldTypeRequiredRulesMeaning
type"start" or "pause" or "next" or "browse" or "reward"yesstart, pause, next or browse for a natural break; reward for a rewarded offer.
namestringyesmatches ^[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.

json
{
  "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.

FieldTypeRequiredRulesMeaning
forUUIDyesThe 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.

json
{
  "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.

FieldTypeRequiredRulesMeaning
forUUIDyesThe id of the adOffer's adBreak.
showbooleanyestrue: the player wants to watch the rewarded ad; false: not now.

The portal validates this payload strictly: unknown fields make the message invalid.

json
{
  "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.

FieldTypeRequiredRulesMeaning
forUUIDyesThe id of the adBreak message this answers.
status"shown" or "dismissed" or "viewed" or "unavailable"yesshown, 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.

json
{
  "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.

FieldTypeRequiredRulesMeaning
mode"invite" or "quick"yesinvite: 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.

json
{
  "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.

FieldTypeRequiredRulesMeaning
matchIdUUIDyesThe match, a server UUID from mp_start.
seqintegeryesat least 0The seq of the mp_state this move was made on. A stale one is answered conflict.
moveany JSON valueyesThe 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.

json
{
  "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.

FieldTypeRequiredRulesMeaning
matchIdUUIDyesThe 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"yesOne 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.

json
{
  "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.

FieldTypeRequiredRulesMeaning
matchIdUUIDyesThe match, a server UUID from mp_start.

The portal validates this payload strictly: unknown fields make the message invalid.

json
{
  "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.

FieldTypeRequiredRulesMeaning
matchIdUUIDyesThe match, a server UUID from mp_start.
mode"invite" or "quick"yesHow the match was made: invite or quick.
resumedbooleanyestrue when an earlier match goes on (the page was reloaded).
turnSecondsintegeryesat least 10; at most 120Seconds per move on the server's clock (the manifest's multiplayer.turnSeconds).
youobjectyesYour seat in this match.
you.seatintegeryesat least 0; at most 7The seat number (0 or 1).
you.seatIdstringyesmatches ^[0-9a-f]{16}$An opaque id for this seat in this match only. Never a user id.
you.handlestringyesmatches ^[0-9A-F]{8}$The public pseudonym (8 hex characters, as on the leaderboards). Show it as the player's name.
you.guestbooleanyestrue when that player is a guest.
opponentsarray of objectyes1 to 7 itemsThe other seats.
opponents[]objectyesOne opponent.
opponents[].seatintegeryesat least 0; at most 7The seat number (0 or 1).
opponents[].seatIdstringyesmatches ^[0-9a-f]{16}$An opaque id for this seat in this match only. Never a user id.
opponents[].handlestringyesmatches ^[0-9A-F]{8}$The public pseudonym (8 hex characters, as on the leaderboards). Show it as the player's name.
opponents[].guestbooleanyestrue 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.

json
{
  "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.

FieldTypeRequiredRulesMeaning
matchIdUUIDyesThe match, a server UUID from mp_start.
seqintegeryesat least 0Moves applied so far. Send it back with your next mp_move.
status"active" or "finished"yesactive or finished.
toMoveinteger or nullyesat least 0; at most 7The seat to move, or null when the match is over.
deadlineAtISO 8601 time or nullyesWhen the seat to move runs out of time (server time), or null.
serverTimeISO 8601 timeyesThe server's now: count the move clock from it, never from the device clock (ADR-005).
stateany JSON valueyesThe game's state as the server's rules engine keeps it. Parse it defensively.
lastMoveobject or nullyesThe move that led here, or null at the start.
lastMove.seatintegeryesat least 0; at most 7The seat that moved.
lastMove.moveany JSON valueyesThe move as the game sent it.
lastMove.autobooleanyestrue when the server played it because the seat ran out of time.
resultobject or nullyesnull while the match runs.
result.winnerinteger or nullyesat least 0; at most 7The winning seat, or null for a draw or an abandoned match.
result.reason"normal" or "resign" or "timeout" or "abandoned"yesnormal, resign, timeout or abandoned.
connectedarray of booleanyesat most 8 itemsPer 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.

json
{
  "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.

FieldTypeRequiredRulesMeaning
reason"cancelled" or "expired" or "invite_invalid" or "fallback_ai" or "error"yescancelled, 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.

json
{
  "sdk": "portal-sdk",
  "v": 1,
  "type": "mp_cancel",
  "id": "3f2b8c1a-5d4e-4f6a-8b7c-9d0e1f2a3b4c",
  "payload": {
    "reason": "fallback_ai"
  }
}

Error codes #

CodeMeaningWhat the game does
too_largeThe 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_limitedA 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.
conflictThe 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.
invalidThe 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.
networkThe 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).
unauthorizedThe player's session expired.Nothing to do: the portal renews the session and sends init again over the port; the bridge then retries.
unsupported_versionThe 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 #

ExportUse
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.
gameManifestSchemaThe 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, ... ackSchemaEvery 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.
ts
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).

İlerlemeni kaydet

E-postana bir bağlantı gönderelim ya da Google ile devam et. Misafir olarak kazandıkların hesabına taşınır.