Docs · Reference

Game protocol

A published game runs inside the Player's frame and talks to it over postMessage in the game protocol (Player protocol 2): it reports loading progress, follows start and pause, and calls platform features such as leaderboards, saves and rooms.

How it works

A published game lives at play.origingame.ai/g/<slug>. The Player loads your release in an iframe on the same play origin and owns everything that is not the game: player identity, platform calls, the offline outbox, loading and error screens, session analytics.

  • The platform injects no code into the game: there is no window.OG and no SDK script to bundle.
  • A game that does not speak the protocol still plays: the Player takes the iframe's load event as ready and shows real download progress from the release's own files.
  • A game that sends hello owns its readiness from then on and must send ready (or fail); otherwise it stays behind the loading screen.
  • Every platform feature is optional. Outside the Player the same code runs unchanged, and every call should answer unavailable instead of waiting forever.
  • Releases published with the old injected SDK (protocol 1, window.OG) keep working; new releases get no SDK.

Security rules:

  • Post only to window.parent, naming your own location.origin as the target origin (the Player and the release share the origin); never "*".
  • Accept a message only when event.source === window.parent and event.origin === location.origin, and only handle og: "player", protocol: 2 messages.
  • No credential crosses the wire. The player credential stays in the Player; a call names only a method. Never put your account API token or any model key in a game.

Lifecycle

Every message is { og: "player", protocol: 2, kind, ...fields }.

Game to Player:

kindFieldsMeaning
hellosubstrate?the game speaks protocol 2 and owns its readiness from now on; send once at startup, before the page's load
loadingvalue, label?real progress from 0 to 1, never backwards; null while the total is unknown. 1 means download complete, not ready
readythe first frame that actually drew something; the Player reveals the game
failcode, message, retryablebefore ready: loading failed. When retryable is true the Player offers Retry (which reloads the iframe)
callid, method, args?a platform request; the Player answers once with a result carrying the same id

Player to game:

kindFieldsMeaning
hellolocale, region, capabilities, started, pausedthe answer to the game's hello; sent again whenever the game says hello again
startplay started (the Play tap, or at once without a start gate); start clocks, music and the first level now
gesturea real user activation happened; an AudioContext may resume now
pause / resumethe Player covers the game (menu, sharing) or the page is hidden, and when that ends
resultid, ok, data or errorthe answer to one call

The fields of the Player's hello:

  • locale: the Player's language.
  • region: the store and compliance region; global is the only one today.
  • capabilities: the methods this Player answers for this page right now, plus pause when it sends pause/resume. Read it instead of probing with calls.
  • started: play has already started. When true, no start will follow; start now.
  • paused: the game is currently paused.

The Player may prerender the page or load the game behind its start gate, so do not start play when loading finishes; wait for start or hello.started.

Minimal integration

The module below follows the protocol's reference implementation: outside the Player it starts at once and every call answers unavailable immediately; inside a frame with no Player hello within 3 seconds it plays alone as well; every call has its own timeout (the Player does not answer a method it does not know).

// platform.js: the game's side of Player protocol 2
const ORIGIN = location.origin
const framed = window.parent !== window
const HOST_WAIT_MS = 3000      // no Player hello by then: play alone
const CALL_TIMEOUT_MS = 20000  // a call nobody answers fails as timeout

let host = null                // the Player's hello: locale, region, capabilities
let alone = !framed
let paused = false
let seq = 0
const pending = new Map()
const handlers = { pause() {}, resume() {}, gesture() {} }

let markStarted
export const playStarts = new Promise((resolve) => { markStarted = resolve })
export const on = (name, fn) => { handlers[name] = fn }
export const context = () => host
export const isPaused = () => paused

export function send(kind, fields = {}) {
  if (framed) window.parent.postMessage({ og: 'player', protocol: 2, kind, ...fields }, ORIGIN)
}

const failure = (code, message, retryable = false) => Object.assign(new Error(message), { code, retryable })

export function call(method, args = {}) {
  if (alone) return Promise.reject(failure('unavailable', 'no Player'))
  if (host && !host.capabilities.includes(method)) return Promise.reject(failure('unavailable', method + ' is not offered here'))
  return new Promise((resolve, reject) => {
    const id = 'c' + ++seq
    const timer = setTimeout(() => { pending.delete(id); reject(failure('timeout', method + ' timed out', true)) }, CALL_TIMEOUT_MS)
    pending.set(id, { resolve, reject, timer })
    send('call', { id, method, args })
  })
}

function setPaused(value) {
  if (paused === value) return
  paused = value
  if (value) handlers.pause()
  else handlers.resume()
}

export const loading = (value, label) => send('loading', label ? { value, label } : { value })
export const ready = () => send('ready')
export const fail = (code, message, retryable) => send('fail', { code, message, retryable })

window.addEventListener('message', (event) => {
  if (event.source !== window.parent || event.origin !== ORIGIN) return
  const m = event.data
  if (!m || m.og !== 'player' || m.protocol !== 2) return
  if (m.kind === 'hello') {
    host = m
    alone = false
    setPaused(m.paused)
    if (m.started) markStarted()
  } else if (m.kind === 'start') {
    markStarted()
  } else if (m.kind === 'pause') {
    setPaused(true)
  } else if (m.kind === 'resume') {
    setPaused(false)
  } else if (m.kind === 'gesture') {
    handlers.gesture()
  } else if (m.kind === 'result') {
    const waiter = pending.get(m.id)
    if (!waiter) return
    pending.delete(m.id)
    clearTimeout(waiter.timer)
    if (m.ok) waiter.resolve(m.data)
    else waiter.reject(Object.assign(new Error(m.error.message), m.error))
  }
})

if (framed) {
  send('hello', { substrate: 'canvas2d' })
  setTimeout(() => {
    if (host) return
    alone = true
    for (const [id, waiter] of pending) {
      clearTimeout(waiter.timer)
      pending.delete(id)
      waiter.reject(failure('unavailable', 'no Player'))
    }
    markStarted()
  }, HOST_WAIT_MS)
} else {
  markStarted()
}

Using it in the game (replace game, audio, loadAssets and drawTitle with your own code):

import { call, fail, isPaused, loading, on, playStarts, ready } from './platform.js'

on('pause', () => game.pause())      // hold the clock and mute
on('resume', () => game.resume())
on('gesture', () => audio.resume())  // an AudioContext may resume now

try {
  await loadAssets((value) => loading(value))  // real progress 0..1
} catch (error) {
  fail('assets', 'Could not load assets', true)
  throw error
}
requestAnimationFrame(() => {
  drawTitle()
  requestAnimationFrame(ready)       // after the first drawn frame
})

await playStarts                     // the Play tap, or no Player
game.start()
if (isPaused()) game.pause()

try {
  const result = await call('leaderboard.submit', { score: 1200 })
  console.log(result.rank)
} catch (error) {
  // error.code and error.retryable are described below; a failed call must never break play
}

Platform calls

call methods are a fixed list; a method outside it gets no answer.

MethodArgumentsAnswer
player.namethe display name
player.info{ name, linked }; linked says whether this player is bound to a sign-in account
player.rename{ name }{ ok, name }, names of 2–24 characters
save.getthe saved data; a save not yet delivered is read back first
save.read{ data, revision }: the save and the revision a conditional save.set names (0 before the first save; revision: null, queued: true while a save still waits on the device)
save.set{ data, revision? }{ ok, revision }; offline { ok: true, queued: true }. With revision the write lands only while the save is still at that revision (conflict otherwise, with the current one in error.revision) and is never queued
store.get{ key }{ key, value, revision }; value null and revision 0 for a key never written
store.set{ key, value, revision? }{ key, revision }. With revision the write lands only while the key is at it (0: never written; conflict otherwise, with error.revision); without it the last write wins. Never queued
store.list{ keys: [{ key, revision }] }
leaderboard.submit{ score, board?, order? }{ ok, rank, board, best }: this run's rank and the player's best on the board; offline { ok: true, rank: null, queued: true }, delivered later in order
leaderboard.top{ n?, board? }{ board, sort, top: [{ rank, name, score }], me }
leaderboard.around{ n?, board? }{ board, sort, entries: [{ rank, name, score, me }], me }: n entries around the player's rank; the top n and me: null before their first score
point.award{ reason, amount? }{ granted, balance }: asks for Origin Point for something the player did; the platform decides. No game is granted any today ({ granted: 0, balance: null })
ai.mark{ kind, label, model? }{ marked: true }: discloses AI-generated content the game shows (kind such as text, image, audio; label what the player sees, up to 120 characters; model when known)
share.link{ url }, the game's public link
share.card{ status: 'opened' or 'unavailable' }
room.ticketthe room intent, see Multiplayer roomsa room ticket; the game opens the WebSocket itself
native.admission{ op, … }native multiplayer admission: list, create, join, getSession, leave, close, with the page's releaseId
ui.fullscreen{ enable? }whether fullscreen is active. Route a fullscreen button through this call so the Player fullscreens itself and keeps its chrome and orientation lock

Notes:

  • The protocol has no sign-in call. The linked of player.info only tells you whether this player is bound to a sign-in account.
  • A leaderboard's default name is default. Its first submission fixes its order: desc (higher is better, the default) or asc (lower is better, for times); asking for the other order later answers conflict.
  • For save.set, pass as revision the one save.read or your last save.set returned, or 0 while there is no save yet. On conflict, read the latest with save.read before merging. Keys of the store work the same way, one key at a time: use them for things saved independently (settings, progress, unlocks).
  • A game that shows "new best" reads best from the leaderboard.submit answer instead of keeping its own copy.
  • While offline, saves and scores wait on the device (the latest save wins, scores keep their order) for up to 7 days.
  • In a readiness verification run (see Starter and readiness check) the Player answers save.* and store.* from a throwaway store (nothing is kept or sent), share.link and ui.fullscreen, and lists exactly those in capabilities.
  • native.admission is only for games whose operator provisioned a native game server; other games get not_found or unauthorized and should fall back to single player.

Error codes

A failed result carries error: { code, message, retryable, status? }. Branch on code, never on the message text.

codeRetryableTypical cause
offlineyesnetwork unreachable
timeoutyesrequest timed out
unavailableyesservice temporarily unavailable (5xx)
rate_limitedyestoo many calls (429)
invalidnoinvalid arguments (400, 422)
unauthorizednonot permitted (401, 403)
not_foundnodoes not exist (404)
conflictnorevision or sort-order conflict (409)
too_largenocontent too large (413)
failednoany other failure

status is the HTTP status the Player saw, when there was one. The platform.js above also rejects with unavailable when there is no Player or capabilities lacks the method, with retryable false.

Limits

ItemLimit
Save size256 KB (JSON) per player per game
Save writes12 per minute per player per game
Rename2–24 characters, 5 per hour per player
Scorea finite number from 0 to 1e12
Leaderboard name1–32 characters of a-z, 0-9, _, -
n of leaderboard.top and leaderboard.around1–100, default 10
Store key1–64 characters of letters, digits, ., _, -
Store value64 KiB (JSON)
Store per player per game64 keys and 1 MiB; 30 writes a minute
ai.mark64 distinct marks per release; 30 a minute per player per game
point.awardamount 1–1000; 30 a minute per player per game
id of a callat most 64 characters

Runtime data

A game may also send these messages; the Player files them with the play session's analytics:

kindFieldsNotes
crashname, fingerprintafter ready the game stopped working. Only an error name and a short fingerprint ([a-z0-9-]{1,64}) leave the page; the Player shows an error with Retry
sampleframes?, gauges?, substrate?, renderer?, gpu?, timings?a performance sample; every 30 seconds is plenty
eventname, attrs?a game event
  • sample.frames is a window of at least 10 frames: frames, fps, frameP50Ms, frameP95Ms, frameP99Ms, jankRatio (the share of frames over twice the median and at least 25 ms), longFrames (frames over 50 ms).
  • sample.gauges: drawCalls, primitives, memoryMb, videoMemoryMb. gpu is reduced to a vendor family. timings are startup milestones in ms since navigation start, for example firstFrame.
  • event.name is one of game.progression.started, game.progression.completed, game.progression.failed (with the level in attrs.node), or game.custom.<name> (a name of 1–64 letters, digits, . and _, such as game.custom.boss.defeated).
  • attrs are flat strings, numbers, booleans or null, at most 16 keys, strings up to 512 characters.
import { send } from './platform.js'

send('event', { name: 'game.progression.completed', attrs: { node: 'level-3' } })

The Player counts a play only after start.

Multiplayer rooms

Rooms need the release published with max_players set from 2 to 16; otherwise room.ticket answers not_found. Rooms are relay rooms (relay) by default: the server only forwards messages. To run authoritative logic on the platform, put origingame.multiplayer.json at the build root:

{ "version": 1, "authority": "server", "module": "server/room.mjs", "tickRate": 20, "idleTimeoutSeconds": 300 }

server/room.mjs must be a single self-contained JavaScript file without imports (at most 512 KiB); it runs in an isolated sandbox with no files, network or Node APIs. tickRate is 1–60 (default 20) and idleTimeoutSeconds 30–3600 (default 300).

Getting a ticket

The arguments of room.ticket are the room intent (at most 4 KB):

opOther fieldsUse
createmaxPlayers?create a room
joincodejoin with a 6-character room code
matchpool? (at most 64 characters)join a room with a free seat, or create one
resumecodereturn to the room after a disconnect

You may include the current releaseId (window.__OG_GAME.releaseId). When the release has changed the answer is { requiresReload: true, ... }; reload the game. On success the answer is { ticket, expiresInMs, endpoint, releaseId, authority, clientUrl, room? }. A ticket is valid for 30 seconds and can be used once.

Connecting the WebSocket

  1. Connect to the returned endpoint (a wss:// URL; do not hard-code it).
  2. Within 5 seconds send { "op": "auth", "ticket": "..." } and receive { "ev": "ready" }.
  3. Send the operation the ticket was issued for: { "op": "create" }, { "op": "join", "code": "ABC234" } or { "op": "match", "pool": "..." }.
  4. Receive created, joined or matched with roomId, code, selfId, hostId, peers, maxPlayers and resumeToken.
  5. Broadcast with { "op": "send", "data": ... }, or add "to": "<peerId>" to send to one player; they receive { "ev": "msg", "from": ..., "data": ... }. Send { "op": "leave" } to leave.

Other events: peer_joined, peer_left, peer_reconnecting, peer_resumed, host_changed, room_closed (with reason) and error (with code and fatal). In a server-authoritative room to cannot address another player directly, and the server's messages come from server.

After a disconnect you can resume within 15 seconds: get a new ticket with op: "resume" and the room code, connect, send { "op": "auth", "ticket": "...", "resumeToken": "..." } and receive resumed.

ItemLimit
One data payload8 KB (JSON)
One WebSocket frame10 KB
Message rate30 per second per connection
Room size2–16
Tickets30 per minute per player (same IP)

Send at a fixed cadence and tolerate loss.

Starter and readiness check

The sdk_scaffold tool of Origin MCP returns a starter project that speaks protocol 2: index.html (hello, ready on the first drawn frame, start and pause, leaderboard and save calls), the protocol guide PLAYER-PROTOCOL.md and README.md. It only returns file contents; your tools write them.

The platform's official Web templates (three.js and canvas2d) implement the lifecycle and calls in src/core/platform.ts and src/core/readiness.ts; the Web shell of Origin Engine games speaks the same protocol.

After every publication the Player opens the new release once in verification mode and records the protocol, the hello and ready times, any failure or crash, the save and store calls it answered from its throwaway store, and a first-frame screenshot; the result appears as the release's readiness. Publishing is covered in Publish a game.