游戏协议
已发布的游戏运行在 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 | 字段 | 含义 |
|---|---|---|
hello | substrate? | 声明说 protocol 2,从此自己负责就绪。启动时发一次,在页面 load 之前 |
loading | value、label? | 真实进度,0 到 1,只增不减;总量未知时为 null。1 表示下载完成,不等于就绪 |
ready | 第一帧真正画出内容。Player 这时显示游戏 | |
fail | code、message、retryable | 在 ready 之前:加载失败。retryable 为 true 时 Player 提供重试(重新加载 iframe) |
call | id、method、args? | 平台调用,Player 用同一个 id 回一次 result |
Player 发给游戏:
kind | 字段 | 含义 |
|---|---|---|
hello | locale、region、capabilities、started、paused | 对游戏 hello 的回复;游戏再次 hello 时会再发一次 |
start | 开始游玩(玩家点了开始,或没有开始门时立即发)。这时才启动计时、音乐和第一关 | |
gesture | 玩家有了真实操作,可以恢复 AudioContext | |
pause / resume | Player 遮住游戏(菜单、分享)或页面被隐藏,以及恢复时 | |
result | id、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 的 n | 1–100,默认 10 |
| 存储键 | 1–64 个字符,字母、数字、.、_、- |
| 存储值 | 64 KiB(JSON) |
| 每个玩家每个游戏的存储 | 64 个键、共 1 MiB;每分钟写入 30 次 |
ai.mark | 每个版本最多 64 条不同的标注;每个玩家每个游戏每分钟 30 次 |
point.award | amount 1–1000;每个玩家每个游戏每分钟 30 次 |
call 的 id | 最长 64 个字符 |
运行数据
游戏可以额外发这些消息,Player 会把它们记入本次游玩的统计:
kind | 字段 | 说明 |
|---|---|---|
crash | name、fingerprint | 在 ready 之后游戏停止工作。只发错误名和短指纹([a-z0-9-]{1,64}),Player 显示错误和重试 |
sample | frames?、gauges?、substrate?、renderer?、gpu?、timings? | 性能样本,每 30 秒一次足够 |
event | name、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 | 其他字段 | 用途 |
|---|---|---|
create | maxPlayers? | 新建房间 |
join | code | 用 6 位房间码加入 |
match | pool?(最长 64 个字符) | 匹配一个有空位的房间,没有就新建 |
resume | code | 断线后回到原房间 |
可以带上当前的 releaseId(window.__OG_GAME.releaseId)。版本已更新时返回 { requiresReload: true, ... },请重新加载游戏。成功时返回 { ticket, expiresInMs, endpoint, releaseId, authority, clientUrl, room? }。票据 30 秒内有效、只能用一次。
连接 WebSocket
- 连接返回的
endpoint(wss://地址,不要写死)。 - 5 秒内发送
{ "op": "auth", "ticket": "..." },收到{ "ev": "ready" }。 - 发送和票据一致的操作:
{ "op": "create" }、{ "op": "join", "code": "ABC234" }或{ "op": "match", "pool": "..." }。 - 收到
created、joined或matched,里面有roomId、code、selfId、hostId、peers、maxPlayers和resumeToken。 - 用
{ "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。
| 项目 | 限制 |
|---|---|
单条 data | 8 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 的时间、失败或崩溃、它用临时存储回答的存档与存储调用,以及首帧截图,结果显示在版本的就绪状态里。发布流程见 发布游戏。