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.OGand no SDK script to bundle. - A game that does not speak the protocol still plays: the Player takes the iframe's
loadevent as ready and shows real download progress from the release's own files. - A game that sends
helloowns its readiness from then on and must sendready(orfail); 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 ownlocation.originas the target origin (the Player and the release share the origin); never"*". - Accept a message only when
event.source === window.parentandevent.origin === location.origin, and only handleog: "player",protocol: 2messages. - No credential crosses the wire. The player credential stays in the Player; a
callnames 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:
kind | Fields | Meaning |
|---|---|---|
hello | substrate? | the game speaks protocol 2 and owns its readiness from now on; send once at startup, before the page's load |
loading | value, label? | real progress from 0 to 1, never backwards; null while the total is unknown. 1 means download complete, not ready |
ready | the first frame that actually drew something; the Player reveals the game | |
fail | code, message, retryable | before ready: loading failed. When retryable is true the Player offers Retry (which reloads the iframe) |
call | id, method, args? | a platform request; the Player answers once with a result carrying the same id |
Player to game:
kind | Fields | Meaning |
|---|---|---|
hello | locale, region, capabilities, started, paused | the answer to the game's hello; sent again whenever the game says hello again |
start | play started (the Play tap, or at once without a start gate); start clocks, music and the first level now | |
gesture | a real user activation happened; an AudioContext may resume now | |
pause / resume | the Player covers the game (menu, sharing) or the page is hidden, and when that ends | |
result | id, ok, data or error | the answer to one call |
The fields of the Player's hello:
locale: the Player's language.region: the store and compliance region;globalis the only one today.capabilities: the methods this Player answers for this page right now, pluspausewhen it sendspause/resume. Read it instead of probing with calls.started: play has already started. When true, nostartwill 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.
| Method | Arguments | Answer |
|---|---|---|
player.name | the 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.get | the 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.ticket | the room intent, see Multiplayer rooms | a 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
linkedofplayer.infoonly 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) orasc(lower is better, for times); asking for the other order later answersconflict. - For
save.set, pass asrevisionthe onesave.reador your lastsave.setreturned, or0while there is no save yet. Onconflict, read the latest withsave.readbefore 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
bestfrom theleaderboard.submitanswer 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.*andstore.*from a throwaway store (nothing is kept or sent),share.linkandui.fullscreen, and lists exactly those incapabilities. native.admissionis only for games whose operator provisioned a native game server; other games getnot_foundorunauthorizedand 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.
code | Retryable | Typical cause |
|---|---|---|
offline | yes | network unreachable |
timeout | yes | request timed out |
unavailable | yes | service temporarily unavailable (5xx) |
rate_limited | yes | too many calls (429) |
invalid | no | invalid arguments (400, 422) |
unauthorized | no | not permitted (401, 403) |
not_found | no | does not exist (404) |
conflict | no | revision or sort-order conflict (409) |
too_large | no | content too large (413) |
failed | no | any 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
| Item | Limit |
|---|---|
| Save size | 256 KB (JSON) per player per game |
| Save writes | 12 per minute per player per game |
| Rename | 2–24 characters, 5 per hour per player |
| Score | a finite number from 0 to 1e12 |
| Leaderboard name | 1–32 characters of a-z, 0-9, _, - |
n of leaderboard.top and leaderboard.around | 1–100, default 10 |
| Store key | 1–64 characters of letters, digits, ., _, - |
| Store value | 64 KiB (JSON) |
| Store per player per game | 64 keys and 1 MiB; 30 writes a minute |
ai.mark | 64 distinct marks per release; 30 a minute per player per game |
point.award | amount 1–1000; 30 a minute per player per game |
id of a call | at most 64 characters |
Runtime data
A game may also send these messages; the Player files them with the play session's analytics:
kind | Fields | Notes |
|---|---|---|
crash | name, fingerprint | after 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 |
sample | frames?, gauges?, substrate?, renderer?, gpu?, timings? | a performance sample; every 30 seconds is plenty |
event | name, attrs? | a game event |
sample.framesis 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.gpuis reduced to a vendor family.timingsare startup milestones in ms since navigation start, for examplefirstFrame.event.nameis one ofgame.progression.started,game.progression.completed,game.progression.failed(with the level inattrs.node), orgame.custom.<name>(a name of 1–64 letters, digits,.and_, such asgame.custom.boss.defeated).attrsare flat strings, numbers, booleans ornull, 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):
op | Other fields | Use |
|---|---|---|
create | maxPlayers? | create a room |
join | code | join with a 6-character room code |
match | pool? (at most 64 characters) | join a room with a free seat, or create one |
resume | code | return 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
- Connect to the returned
endpoint(awss://URL; do not hard-code it). - Within 5 seconds send
{ "op": "auth", "ticket": "..." }and receive{ "ev": "ready" }. - Send the operation the ticket was issued for:
{ "op": "create" },{ "op": "join", "code": "ABC234" }or{ "op": "match", "pool": "..." }. - Receive
created,joinedormatchedwithroomId,code,selfId,hostId,peers,maxPlayersandresumeToken. - 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.
| Item | Limit |
|---|---|
One data payload | 8 KB (JSON) |
| One WebSocket frame | 10 KB |
| Message rate | 30 per second per connection |
| Room size | 2–16 |
| Tickets | 30 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.