文档 · 参考

游戏协议

已发布的游戏运行在 Player 的框架页里,通过 postMessage 和 Player 说游戏协议(Player protocol 2),用来报告加载进度、响应开始和暂停,并调用排行榜、存档、房间等平台能力。

工作方式

游戏发布后的地址是 play.origingame.ai/g/<slug>。Player 在同一个 play 域名下用 iframe 加载你的版本,负责游戏之外的一切:玩家身份、平台调用、离线暂存、加载和出错界面、会话统计。

  • 平台不向游戏注入任何代码,没有 window.OG,也没有需要打包的 SDK 脚本。
  • 不说协议的游戏照样能玩:Player 以 iframe 的 load 事件作为就绪,并按版本自己的文件显示真实下载进度。
  • 说协议的游戏在发出 hello 之后自己负责就绪,必须发 ready(或 fail),否则会一直停在加载界面。
  • 每个平台能力都是可选的。不在 Player 里运行时,同一份代码照常运行,所有调用都应该返回不可用,而不是一直等待。
  • 用旧版注入 SDK(protocol 1,window.OG)发布的版本继续可用;新发布的版本不再带 SDK。

安全规则:

  • 只向 window.parent 发消息,目标 origin 写自己的 location.origin(Player 和版本同域),永远不用 "*"。
  • 只接受 event.source === window.parent 且 event.origin === location.origin 的消息,并且只处理 og: "player"、protocol: 2 的消息。
  • 消息里没有任何凭据。玩家凭据只在 Player 手里;call 只写方法名。不要把账号 API Token 或任何模型 Key 放进游戏。

生命周期

每条消息都是 { og: "player", protocol: 2, kind, ...字段 }。

游戏发给 Player:

kind字段含义
hellosubstrate?声明说 protocol 2,从此自己负责就绪。启动时发一次,在页面 load 之前
loadingvalue、label?真实进度,0 到 1,只增不减;总量未知时为 null。1 表示下载完成,不等于就绪
ready第一帧真正画出内容。Player 这时显示游戏
failcode、message、retryable在 ready 之前:加载失败。retryable 为 true 时 Player 提供重试(重新加载 iframe)
callid、method、args?平台调用,Player 用同一个 id 回一次 result

Player 发给游戏:

kind字段含义
hellolocale、region、capabilities、started、paused对游戏 hello 的回复;游戏再次 hello 时会再发一次
start开始游玩(玩家点了开始,或没有开始门时立即发)。这时才启动计时、音乐和第一关
gesture玩家有了真实操作,可以恢复 AudioContext
pause / resumePlayer 遮住游戏(菜单、分享)或页面被隐藏,以及恢复时
resultid、ok、data 或 error某次 call 的回答

hello 里的字段:

  • locale:Player 的语言。
  • region:商店与合规地区,目前只有 global。
  • capabilities:这个 Player 此刻对这个页面能回答的方法,以及会发 pause/resume 时的 pause。先看它,不要靠试调用判断。
  • started:已经开始游玩。为 true 时不会再等到 start,直接开始。
  • paused:当前处于暂停。

Player 可能会预渲染页面或把游戏加载在开始门后面,所以不要在加载完时就开始游戏,要等 start 或 hello.started。

最小接入

下面的模块按 Player 协议的参考实现写成:不在 Player 里时直接开始、所有调用立即返回 unavailable;在框架里 3 秒内没收到 Player 的 hello 也按单机处理;每个调用有自己的超时(不认识的方法 Player 不会回答)。

// platform.js:游戏一侧的 Player protocol 2
const ORIGIN = location.origin
const framed = window.parent !== window
const HOST_WAIT_MS = 3000      // 这么久没有 Player 的 hello,就单机运行
const CALL_TIMEOUT_MS = 20000  // 没人回答的调用按 timeout 失败

let host = null                // Player 的 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()
}

在游戏里使用(game、audio、loadAssets、drawTitle 换成你自己的代码):

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

on('pause', () => game.pause())      // 停住时钟并静音
on('resume', () => game.resume())
on('gesture', () => audio.resume())  // 可以恢复 AudioContext 了

try {
  await loadAssets((value) => loading(value))  // 真实进度 0..1
} catch (error) {
  fail('assets', 'Could not load assets', true)
  throw error
}
requestAnimationFrame(() => {
  drawTitle()
  requestAnimationFrame(ready)       // 第一帧画出来之后
})

await playStarts                     // 玩家点了开始,或者没有 Player
game.start()
if (isPaused()) game.pause()

try {
  const result = await call('leaderboard.submit', { score: 1200 })
  console.log(result.rank)
} catch (error) {
  // error.code 和 error.retryable 见下文;调用失败不能影响游玩
}

平台调用

call 的方法名是固定的列表,不在列表里的方法得不到回答。

方法参数返回
player.name玩家显示名
player.info{ name, linked },linked 表示这个玩家是否已绑定登录账号
player.rename{ name }{ ok, name },名字 2–24 个字符
save.get存档数据;有还没送达的存档时先读回它
save.read{ data, revision }:存档及其版本号,供带条件的 save.set 使用(还没有存档时为 0;有存档仍在设备上排队时为 revision: null, queued: true)
save.set{ data, revision? }{ ok, revision };离线时 { ok: true, queued: true }。带 revision 时只在存档仍是该版本时写入(否则 conflict,error.revision 为当前版本),且不会进入离线队列
store.get{ key }{ key, value, revision };从未写过的键 value 为 null、revision 为 0
store.set{ key, value, revision? }{ key, revision }。带 revision 时只在该键仍是这个版本时写入(0 表示从未写过;否则 conflict,带 error.revision);不带则以最后一次为准。不进入离线队列
store.list{ keys: [{ key, revision }] }
leaderboard.submit{ score, board?, order? }{ ok, rank, board, best }:本次成绩的名次和玩家在该板上的最好成绩;离线时 { ok: true, rank: null, queued: true },之后按顺序补交
leaderboard.top{ n?, board? }{ board, sort, top: [{ rank, name, score }], me }
leaderboard.around{ n?, board? }{ board, sort, entries: [{ rank, name, score, me }], me }:玩家名次附近的 n 条;还没有成绩时为前 n 名,me 为 null
point.award{ reason, amount? }{ granted, balance }:为玩家做到的事申请源点,由平台决定;目前不向任何游戏发放({ granted: 0, balance: null })
ai.mark{ kind, label, model? }{ marked: true }:标注游戏展示的 AI 生成内容(kind 如 text、image、audio;label 为玩家看到的名称,最长 120 个字符;model 已知时填写)
share.link{ url },游戏的公开链接
share.card{ status: 'opened' 或 'unavailable' }
room.ticket房间意图,见 多人房间房间票据;WebSocket 由游戏自己打开
native.admission{ op, … }原生多人准入:list、create、join、getSession、leave、close,带页面的 releaseId
ui.fullscreen{ enable? }当前是否全屏。全屏按钮请走这个调用,由 Player 自己全屏,保留它的界面和方向锁定

补充说明:

  • 协议里没有登录调用。player.info 的 linked 只告诉你这个玩家是否已绑定登录账号。
  • 排行榜默认板名为 default。一个板的排序由第一次提交决定:desc(越高越好,默认)或 asc(越低越好,适合计时);之后要求相反的排序会得到 conflict。
  • save.set 的 revision 用 save.read 或上次 save.set 返回的版本号,还没有存档时为 0。收到 conflict 时先 save.read 读回最新数据再合并。存储的键也一样,按键各自进行:设置、进度、解锁等彼此独立保存的内容请用不同的键。
  • 要显示“新纪录”时,直接读 leaderboard.submit 返回的 best,不必自己另存一份。
  • 离线时存档和成绩暂存在设备上(存档以最后一次为准,成绩按顺序),最多保留 7 天。
  • 就绪校验运行(见 起步模板与就绪校验)里,Player 用一个临时存储回答 save.* 和 store.*(不保留、不发送),另外只回答 share.link 和 ui.fullscreen,capabilities 里正好列出这些。
  • native.admission 只用于运营为其开通了原生游戏服务器的游戏,其他游戏会得到 not_found 或 unauthorized,请回退到单人模式。

错误码

失败的 result 带 error: { code, message, retryable, status? }。按 code 分支,不要匹配 message 文本。

code可重试典型原因
offline是网络不可达
timeout是请求超时
unavailable是服务暂不可用(5xx)
rate_limited是调用太频繁(429)
invalid否参数无效(400、422)
unauthorized否无权限(401、403)
not_found否不存在(404)
conflict否版本或排序冲突(409)
too_large否内容太大(413)
failed否其他失败

status 是 Player 看到的 HTTP 状态码(如果有)。上面的 platform.js 在没有 Player 或 capabilities 不含该方法时,也用 unavailable 拒绝,但 retryable 为 false。

限制

项目限制
存档大小每个玩家每个游戏 256 KB(JSON)
存档写入频率每个玩家每个游戏每分钟 12 次
改名2–24 个字符,每个玩家每小时 5 次
分数0 到 1e12 的有限数
排行榜名1–32 个字符,a-z、0-9、_、-
leaderboard.top 与 leaderboard.around 的 n1–100,默认 10
存储键1–64 个字符,字母、数字、.、_、-
存储值64 KiB(JSON)
每个玩家每个游戏的存储64 个键、共 1 MiB;每分钟写入 30 次
ai.mark每个版本最多 64 条不同的标注;每个玩家每个游戏每分钟 30 次
point.awardamount 1–1000;每个玩家每个游戏每分钟 30 次
call 的 id最长 64 个字符

运行数据

游戏可以额外发这些消息,Player 会把它们记入本次游玩的统计:

kind字段说明
crashname、fingerprint在 ready 之后游戏停止工作。只发错误名和短指纹([a-z0-9-]{1,64}),Player 显示错误和重试
sampleframes?、gauges?、substrate?、renderer?、gpu?、timings?性能样本,每 30 秒一次足够
eventname、attrs?游戏事件
  • sample.frames 是一个至少 10 帧的窗口:frames、fps、frameP50Ms、frameP95Ms、frameP99Ms、jankRatio(超过中位数两倍且不少于 25 ms 的帧占比)、longFrames(超过 50 ms 的帧数)。
  • sample.gauges:drawCalls、primitives、memoryMb、videoMemoryMb。gpu 只保留厂商系列。timings 是从导航开始算起的启动里程碑(毫秒),例如 firstFrame。
  • event.name 只能是 game.progression.started、game.progression.completed、game.progression.failed(在 attrs.node 写关卡),或 game.custom.<名字>(名字为 1–64 个字母、数字、. 与 _,例如 game.custom.boss.defeated)。
  • attrs 是扁平的字符串、数字、布尔值或 null,最多 16 个键,字符串最长 512 个字符。
import { send } from './platform.js'

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

Player 在 start 之后才计一次游玩。

多人房间

房间需要发布时把最大人数 max_players 设为 2 到 16;否则 room.ticket 返回 not_found。默认是中继房间(relay),服务器只转发消息。要在平台上运行权威逻辑,在构建根目录放 origingame.multiplayer.json:

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

server/room.mjs 必须是不含 import 的单文件 JavaScript(最大 512 KiB),在隔离沙箱里运行,没有文件、网络和 Node API。tickRate 为 1–60(默认 20),idleTimeoutSeconds 为 30–3600(默认 300)。

拿票据

room.ticket 的参数就是房间意图(最大 4 KB):

op其他字段用途
createmaxPlayers?新建房间
joincode用 6 位房间码加入
matchpool?(最长 64 个字符)匹配一个有空位的房间,没有就新建
resumecode断线后回到原房间

可以带上当前的 releaseId(window.__OG_GAME.releaseId)。版本已更新时返回 { requiresReload: true, ... },请重新加载游戏。成功时返回 { ticket, expiresInMs, endpoint, releaseId, authority, clientUrl, room? }。票据 30 秒内有效、只能用一次。

连接 WebSocket

  1. 连接返回的 endpoint(wss:// 地址,不要写死)。
  2. 5 秒内发送 { "op": "auth", "ticket": "..." },收到 { "ev": "ready" }。
  3. 发送和票据一致的操作:{ "op": "create" }、{ "op": "join", "code": "ABC234" } 或 { "op": "match", "pool": "..." }。
  4. 收到 created、joined 或 matched,里面有 roomId、code、selfId、hostId、peers、maxPlayers 和 resumeToken。
  5. 用 { "op": "send", "data": ... } 广播消息,加 "to": "<peerId>" 只发给一个人;对方收到 { "ev": "msg", "from": ..., "data": ... }。离开时发 { "op": "leave" }。

其他事件:peer_joined、peer_left、peer_reconnecting、peer_resumed、host_changed、room_closed(带 reason)和 error(带 code、fatal)。服务器权威房间里不能用 to 直接发给其他玩家,服务器的消息 from 为 server。

断线后 15 秒内可以恢复:用 op: "resume" 和房间码拿新票据,连接后发 { "op": "auth", "ticket": "...", "resumeToken": "..." },收到 resumed。

项目限制
单条 data8 KB(JSON)
单个 WebSocket 帧10 KB
消息频率每个连接每秒 30 条
房间人数2–16
拿票据每个玩家(同一 IP)每分钟 30 次

消息尽量用固定频率发送,并容忍丢失。

起步模板与就绪校验

用 Origin MCP 的 sdk_scaffold 工具可以拿到一个说 protocol 2 的起步项目:index.html(hello、首帧 ready、开始和暂停、排行榜和存档调用)、协议说明 PLAYER-PROTOCOL.md 和 README.md。它只返回文件内容,由你的工具写入。

平台官方的 Web 模板(three.js 和 canvas2d)已经在 src/core/platform.ts 和 src/core/readiness.ts 里实现了生命周期和调用;Origin Engine 游戏的 Web 外壳说同一套协议。

每次发布后,Player 会在校验模式下打开一次新版本,记录协议、hello、ready 的时间、失败或崩溃、它用临时存储回答的存档与存储调用,以及首帧截图,结果显示在版本的就绪状态里。发布流程见 发布游戏。