Concepts

The ideas behind the SDK, in the order you meet them: where a game runs, how it connects to the portal, what it may keep and send, and why the clock belongs to the server. Read this once; the SDK reference is the place to look things up afterwards.

The shape of the system #

A player opens the portal. The portal page contains an iframe with your game in it. The game is a folder of static files served from a different origin than the portal, so it cannot touch the portal's pages, cookies or storage. The only way the two talk is by messages.

 player's browser
 ┌───────────────────────────────────────────────────────────┐
 │ portal origin   https://portal.example                    │
 │   portal page: account, XP, rewards, loading screen       │
 │   ┌───────────────────────────────────────────────────┐   │
 │   │ <iframe sandbox="allow-scripts ...">              │   │
 │   │ your game, from the games origin                  │   │
 │   │ https://games.example/<id>/<version>/             │   │
 │   └───────────────────────────────────────────────────┘   │
 └───────────────────────────────────────────────────────────┘
      ▲ │ messages only: postMessage, then a MessageChannel
      │ ▼
 the portal talks to the server: saves, XP, rewards, scores, the clock

Three consequences shape every game:

  1. The game never talks to the server. The portal does, on the game's behalf: it writes saves, scores and events. A game makes no network requests of its own (rule R-NET-02).

  2. The server decides, the game proposes. XP, rewards, streaks, badges and saves are written only by the portal's server. A game can report a score or an event; the server checks whether it is plausible before it counts.

  3. The game learns nothing about the player. It gets a language, a "guest or not" flag, its own save and the server's clock.

Portal mode and standalone mode #

A game runs in two modes with one code path. The bridge decides which, so the game does not.

Portal modeStandalone mode
WhenThe page is inside an iframe and init arrivedThe page is a top-level page, 2 seconds after start
SaveThe portal's cloud save (init.save, save messages)localStorage, key <gameId>:save
LanguageThe portal's (init.lang, then lang messages)The player's own toggle, or the browser's language
DayThe server's (init.time.day, then day messages)There is no server, so no daily content
PauseThe portal's pause and resume (and the hidden tab)The hidden tab
Scores, eventsSent to the portalNot sent: score() and event() resolve null

Standalone mode exists so a game keeps working when it is opened on its own: a shared link, a test site, a regression test. The 2 second rule belongs to the top-level page only:

Önemli

Inside an iframe a game never falls back to standalone mode, however late init is. It keeps sending hello and waits. If it gave up after a few seconds it would create a second, local save next to the cloud save, and the player would lose progress. The portal handles slowness itself: after 10 seconds without ready it shows a retry screen and reloads the frame. The bridge enforces this; do not add your own timeout (R-SDK-06).

The bridge's modes are: booting (created), handshaking (inside an iframe, waiting), portal, standalone, and unsupported (the portal does not speak your SDK version).

The handshake #

  1. The game loads. bridge.start() checks window.parent !== window to see whether it is inside an iframe.

  2. Inside an iframe it sends hello to the portal and repeats it every 250 ms until init arrives. It sends the same hello once more when the page's load event fires: the portal treats a document that finished loading without a hello after its load as a page the game navigated to, and closes it. The portal usually fetches the player's save while the frame loads, so init normally answers the very first hello.

  3. The portal answers the first valid hello with init and a MessageChannel port. The game switches to portal mode and stops sending hello.

  4. When the game is playable it sends ready over the port; the portal closes its loading screen.

  5. Outside an iframe, after 2 seconds the game switches to standalone mode.

If the game page reloads inside the frame, it starts again with a new hello; the portal closes the old port and sends a new init. A game must therefore rebuild its state from the new init and never carry anything over from local storage (R-SDK-08).

If the portal does not support the game's SDK version it answers the hello with an ack carrying unsupported_version. The bridge goes to the unsupported mode; the game stays on a calm screen and the portal shows its own error.

Origins, sandbox and the MessageChannel #

The rules that keep a game and the portal apart, and why each exists:

RuleWhy
The game is served from its own origin. The portal's cookies and storage live on the portal's origin and never reach it.Game code is not trusted with the player's session.
The iframe is sandbox="allow-scripts allow-same-origin allow-pointer-lock" and allow="autoplay; fullscreen; gamepad".The game can run scripts, use its own storage and lock the pointer; it cannot open popups, submit forms, navigate the portal or use the camera and microphone.
games-host sends Content-Security-Policy: frame-ancestors <portal origin> on every response and sets no cookie.Only the portal can embed a game; a game cannot be framed by another site.
The portal accepts hello only when event.source is the game's own frame and event.origin is the origin it expects.A different frame or window cannot pretend to be your game.
The game accepts init only from window.parent at one of the portal origins that games-host injected into the page.A page inside your game, or a popup, cannot pretend to be the portal and hand your game a forged save or clock.
postMessage always names an exact target origin, never "*".A message with target "*" goes to whoever framed the game.
After init, everything goes over the MessageChannel port, and window messages are no longer listened to.The port is private to the two sides, so other scripts on the page cannot interfere. The origin check is needed only once, at the handshake.

The portal's own page is never embeddable and games cannot reach window.parent beyond postMessage (R-SEC-01).

Saves: revisions, history and conflicts #

A save is a JSON value of up to 256 KB that the game owns. The portal stores it per player and per game; the game never sees the player's identity.

Revisions. Every stored save has a revision number. A save carries the revision it was built on (baseRevision); the portal stores it only if that is still the current revision, then answers with the new one. This optimistic lock is what stops two tabs overwriting each other.

   tab A (phone)                 server                    tab B (laptop)
   load: revision 4 ------------------------------------>  load: revision 4
   save, base 4  ---------------> store, now 5
                                                           save, base 4 ----> refused: conflict (server has 5)
                                                           player picks or merge ; save, base 5 ----> store, now 6

History. Whenever a save is replaced, the old one goes into the player's save history; the newest five are kept. A save that was refused with a conflict is kept there too, for seven days. So whichever way a conflict is resolved, the other side is not gone for good.

Conflicts are answered with the server's save in ack.conflict. The game then either merges both copies without loss or asks the player. See Conflicts for both ways in code.

Save formats. Each save records its saveVersion. A newer build upgrades older saves on load (upgradeSave). The reverse is refused: an older build, such as a stale tab or a rolled-back release, may never overwrite a save written in a newer format. It gets conflict, the bridge reports outdated, and the game asks the player to reload.

Pacing. One save per 30 seconds, plus one immediately when the game is paused or the tab is hidden (so progress is written before the page may go away). The bridge keeps only the newest state while it waits, retries after rate_limited, queues when the network is down and sends when it is back.

Defensive parsing. A save can be missing, empty, from an older format or damaged. A game always reaches ready and a playable state, whatever init.save.data contains (R-STO-06).

Where saves must not go. In portal mode progress lives only behind bridge.storage: no localStorage, sessionStorage, IndexedDB or Cache API (R-STO-01). Two copies of a save is how progress gets lost.

Scores and events #

Both are claims made by the player's device. The portal checks them before it believes them.

  • A score is the number your game ranks players by (score(mode, value)), usually sent once at the end of a round. The mode must be declared in the manifest, with rules for what is plausible: integer, max, monotonic and maxPerSecond. A score that breaks them is stored as suspicious, kept off leaderboards and never moves the baseline.

  • An event (event(name, props)) says that something meaningful happened: a round ended, a milestone was reached. Badges and the daily challenge are built on events. The event and every property must be declared in the manifest.

Both are rate limited (a score per second, five events per second) and both are answered with an ack. The game logs invalid and never retries it; it retries rate_limited and network later.

The portal never takes XP from a message. XP comes from what the portal itself measures (active play time from its heartbeats, the first play of a game) and from what the server decides your events unlock (badges, challenges).

The daily challenge #

A game that declares a challenge in its manifest joins the portal's daily challenge. The manifest names a metric (a score mode, or an event with a number property value) and three targets (easy, medium, hard). The portal picks one target for each player by their stage, and sends it in init.challenge with the day's progress and the time it ends. The game only needs to keep reporting the metric; it can show the target and progress if it likes.

The portal counts progress as the best accepted score of the day (score metric) or the largest value of the day's events (event metric). Because the device is not trusted, the day's progress is capped by how much the player could possibly have earned in the active play time the server measured: challenge.maxPerSecond (default: the hard target in 10 minutes) times the active seconds of that local day.

Server day and server time #

The server's clock is the only clock (ADR-005). The device clock is never trusted: a player can move it, and that would multiply streaks, rewards and offline earnings.

  • The day is the player's local calendar day, decided by the server from the player's time zone. The game receives it as init.time.day and day messages. The player's time zone can change at most once in seven days.

  • Reset. init.time.nextResetAt is the end of the player's day. Reset daily counters and daily content when a day message arrives, and only then; not at local midnight, not when Date says so (R-TIM-02).

  • Offline earnings of idle games are the time between the save's server timestamp (init.save.savedAt) and init.time.serverTime, never a timestamp inside the save (R-TIM-01). During a session, time that passed while paused is measured from resume.serverTime; the rest of the session uses a monotonic clock (performance.now()).

  • Daily content is seeded from the day: dailySeed(gameId, init.time.day) gives everyone the same board on the same day, whatever their device clock says (R-TIM-03).

A game that computes anything from Date.now() or new Date() in portal mode fails R-TIM-01; the runner sets the device clock a day ahead and checks that nothing changes. In code:

ts
import { dailySeed } from "@gameportal/game-kit";

// Daily content: the same board for everyone on the same server day.
const today = bridge.init?.time.day ?? "standalone"; // there is no server day outside the portal
startDailyBoard(dailySeed("my-game", today));

// A new day arrives as a message, never from the device clock.
bridge.on("day", ({ day }) => startDailyBoard(dailySeed("my-game", day)));

// Idle game: offline earnings from the server's clock and the save's server timestamp.
const init = bridge.init;
if (init?.save) {
  const awayMs = Date.parse(init.time.serverTime) - Date.parse(init.save.savedAt);
  creditOfflineEarnings(Math.max(0, Math.min(awayMs, 8 * 3_600_000)) / 1000); // capped at 8 hours
}

Pause, resume, language and day #

The portal pauses a game for overlays (menus, sign-in), ads and the hidden tab; all are one "suspended" signal to the game:

  • On pause (or a hidden tab) the game freezes time, animation and audio, says session(false) so idle time earns no play-time XP, and flushes its pending save (the portal allows one save per pause outside the 30 second limit).

  • On resume the game continues. In the middle of a round it waits for a tap first.

  • On lang it switches language without reloading. In the portal the game hides its own language and pause buttons (R-UX-06).

  • On day it restarts its daily counters.

ts
let suspended = false;

bridge.on("pause", ({ reason }) => {
  suspended = true;
  freezeGame(); // time, animation and audio stop
  bridge.session(false); // idle time earns no play-time XP
  console.info(`paused: ${reason}`); // "hidden", "overlay" or "ad"
});

bridge.on("resume", () => {
  suspended = false;
  showTapToContinue(); // mid-round: wait for a tap before the round goes on
});

document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "hidden" && !suspended) freezeGame(); // a hidden tab is a pause too
});

What a game never sees #

No player id, no name, no e-mail, no session key, no cookies. init.player.guest is the only fact about the player. A game must not ask for or store personal data either (R-PRV-01) and must not ship analytics or ad code: the portal measures play itself (R-NET-04, R-CNT-01).

İ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.