平台简介
在线游戏平台是一个面向网页游戏的联机游戏平台。玩家在浏览器中即可完成游戏发现、开局与对战,无需下载客户端。游戏开发商按平台规范开发一次,即可获得游戏分发、联机对战、匹配、结算与运营数据能力。
平台能力
- 游戏即点即玩:游戏以 Web 静态包形式托管,在大厅点击卡片即可进入,支持分类筛选、搜索、收藏与最近游玩。
- 实时联机:平台统一提供 WebSocket 网关、会话管理、断线重连与在线状态,游戏只需专注玩法逻辑。
- 两种开局方式:quick 模式由平台自动匹配玩家,人数达标后自动开局;room 模式提供房间码,适合好友组局。
- 平台托管结算:玩家金币余额由平台托管,游戏按回合发起扣款与奖励,结算原子完成并全程留痕。
- 公平随机:内置 commit-reveal 公平随机协议,玩家可独立验证每局随机结果。
- 版本化内容审核:游戏前端包与逻辑包分别提审,审核通过即发布,支持版本回滚。
- 运营支撑:管理后台覆盖开发商管理、玩家管理、财务、内容运营、数据看板与客服工单。
角色与入口
| 角色 | 入口 | 职责 |
|---|---|---|
| 玩家 | game.zihua-chen.cn | 注册账号,发现并游玩游戏,管理钱包与个人设置 |
| 开发商 | game.zihua-chen.cn/admin | 开发游戏并提审发布,管理名下游戏与运营数据 |
| 平台运营 | game.zihua-chen.cn/admin | 内容审核、财务管理、内容运营、数据看板与客服支持 |
技术栈
后端基于 Go,依赖 PostgreSQL 与 Redis,由 admin、lobby、game-api 三个服务组成。前端为 Vue 3 单页应用,玩家大厅使用 Tailwind CSS,管理后台使用 Element Plus。游戏逻辑运行在 goja 沙箱中,由平台托管执行。游戏前端包存储在 S3 兼容对象存储上。通信采用 REST 与 WebSocket,平台为游戏两端提供 TypeScript SDK。
使用声明
平台金币为虚拟物品,仅用于学习研究与技术演示,不涉及真实资金交易。充值支付为沙箱模拟流程,金币不可兑回。平台提供充值限额与自我排除等健康游戏工具,见合规与账户安全。
系统架构
服务组成
| 组件 | 说明 |
|---|---|
| 玩家大厅 | Vue 3 单页应用,提供游戏列表、搜索、收藏、游戏宿主、钱包与个人中心 |
| 管理后台 | Vue 3 单页应用,承载平台运营与开发商后台 |
| Admin API | 后台服务,负责管理员与开发商账号、游戏与版本审核、财务、内容运营 |
| Lobby API | 大厅服务,负责玩家注册登录、游戏列表、钱包、签到任务、通知与充值 |
| Game API | WebSocket 网关与游戏逻辑运行时,负责会话状态机、匹配、回合结算与公平随机 |
| PostgreSQL / Redis | 关系存储与缓存,Redis 同时承载实时余额推送 |
| 对象存储 | S3 兼容存储,托管审核通过的游戏前端包 |
身份体系
平台区分三类账号。玩家由大厅自助注册,注册即创建玩家账户与钱包,凭玩家令牌访问大厅与游戏服务。管理后台用户与玩家分属两个独立身份域,RBAC 权限体系只约束后台用户。开发商是后台侧账号,只能操作本开发商名下的游戏。
游戏托管模型
一款游戏由游戏前端与游戏逻辑两部分组成,均由开发商开发、平台托管运行。
- 游戏前端是标准 Web 静态资源,以 iframe 方式嵌入大厅页面运行。
- 游戏逻辑是一份 JavaScript 程序,提交到平台后由隔离运行时托管执行,负责全部权威玩法逻辑与结算。游戏逻辑不暴露任何网络接口,玩家无法绕过前端直接访问。
flowchart LR
subgraph BROWSER["玩家浏览器"]
L["平台大厅"] -- "iframe 嵌入" --> GF["游戏前端<br/>开发商静态页面"]
end
subgraph PLATFORM["平台"]
H["大厅宿主"] -- "postMessage<br/>连接配置与票据" --> GF
G["WebSocket 网关"] --> R["游戏逻辑运行时<br/>托管开发商代码"]
R -- "回合结算" --> W["钱包服务"]
S["静态资源托管<br/>存放游戏前端包"]
P["开放 API"]
end
GF -- "WebSocket 游戏事件" --> G
L -- "REST" --> P
DEV["开发商后台与 CI"] -- "提包、提审、查询" --> P
P -- "审核通过发布" --> S
P -- "审核通过发布" --> R
设计原则:
- 凭证不进游戏前端:游戏前端通过宿主协议向大厅索取一次性 WebSocket 票据,票据单次有效、30 秒过期,玩家长期凭证不会离开大厅。
- 权威在服务端:影响玩法与资金的操作均由平台托管的游戏逻辑裁决,前端只负责表现。
- 资金平台托管:游戏逻辑只能通过平台回合接口发起扣款与奖励,结算原子完成并全程留痕。
对局链路
- 会话状态机为
created → waiting → ready → playing → finished,error 与 timeout 为终态,玩法动作仅在 playing 状态受理。 - quick 模式由匹配器按游戏自动池化玩家,人数达到 minPlayer 即自动开局。
- room 模式通过 REST 创建房间并获得 6 位房间码,其他玩家凭码加入等待室,房主满足人数后开局。
- 观战为会话级只读连接,需游戏声明允许。
资金链路
玩家钱包由余额与流水两部分组成,一切资金变动包裹在显式回合中,整回合原子提交、失败整体回滚。余额变更在事务提交后经 Redis 发布订阅广播,由网关实时推送到玩家的全部连接,游戏内余额显示无需轮询。充值遵循实名认证、下单、渠道回调、同事务入账的闭环,幂等键防止重复入账。
注册与登录
注册账号
进入大厅 game.zihua-chen.cn,点击右上角注册,填写以下信息:
| 字段 | 要求 |
|---|---|
| 用户名 | 必填,3–50 个字符,全局唯一 |
| 昵称 | 选填,最长 20 字符,游戏内展示名,留空时使用用户名 |
| 邮箱 | 必填,用于找回密码 |
| 密码 | 至少 6 位 |
注册即表示已知悉平台使用条款:本平台仅用于学习研究与技术演示,不涉及真实资金交易。注册成功后自动登录,并返回之前访问的页面。
登录
支持用户名加密码登录。未登录状态下点击游戏卡片、充值等入口,会先进入登录页,登录后自动返回。
找回密码
- 在登录页输入注册邮箱,获取 6 位重置码。重置码 10 分钟内有效,沙箱环境下由平台日志下发,可联系平台获取。
- 输入重置码与新密码完成重置。重置后所有设备需重新登录。
个人中心不提供改密码入口,修改密码统一通过找回密码流程完成。
注销账号
个人中心的账户与安全页提供注销入口,操作需输入登录密码确认。注销后账号不可恢复,交易流水按合规要求保留用于对账,注销成功后自动退出登录。
游戏大厅
大厅首页 game.zihua-chen.cn 是玩家发现游戏的主入口,页面自上而下分为以下区块:
| 区块 | 说明 |
|---|---|
| 公告条 | 展示平台公告,内容由运营配置,仅显示标题 |
| 轮播位 | 运营配置的推荐内容,自动轮换,悬停暂停,支持滑动与箭头切换,绑定游戏的轮播可一键进入 |
| 最近在玩 | 登录后展示近期游玩过的游戏,横向滑动,点击继续 |
| 我的收藏 | 登录后展示收藏的游戏 |
| 编辑推荐 | 运营配置的推荐位,点击进入对应游戏 |
| 分类筛选 | 全部与各分类标签,点击过滤下方游戏列表 |
| 游戏网格 | 全部上架游戏,加载中显示骨架屏,失败可重试 |
游戏卡片展示封面、分类角标与标题,悬停出现立即进入按钮,整卡可点击。未登录点击游戏会先进入登录页,登录后自动回到该游戏。
搜索
点击顶栏搜索入口或使用快捷键 Ctrl/⌘+K 打开全屏搜索。
- 输入即搜,最多返回 20 条结果,回车直达第一个结果。
- 无关键词时展示最近 8 条搜索历史与热门游戏入口,历史可一键清除。
- Esc 键先清空关键词,再次按下关闭搜索。
收藏与最近游玩
进入游戏页后可收藏或取消收藏。有游玩记录的游戏出现在首页最近在玩,点击封面直接回到游戏。
开始游戏
进入游戏页后,游戏以 iframe 方式嵌入运行。开局方式由游戏配置决定,分为 quick 与 room 两种。
quick 模式
点击游戏卡片直接进入,无需手动操作。平台自动将玩家加入该游戏的匹配队列,人数达到开局要求后自动开始对局。该模式适合对战与牌桌类玩法。
room 模式
进入游戏页先显示房间面板。
- 创建房间:房主获得 6 位房间码,可复制邀请链接发送给好友。此前有未开始的房间时会自动恢复。
- 加入房间:输入好友的房间码加入,房间码自动转为大写。
等待室展示房间号、状态、玩家数与玩家列表,房主带房主标记。人数满足开局要求后,房主可点击开始游戏,其余玩家等待即可,也可离开房间退出等待。对局开始后等待室收起,游戏画面接管。
对局中
- 余额显示在顶栏并实时更新。游戏内的余额变动由平台实时推送,无需刷新页面。
- 断线或刷新页面后重新进入游戏,平台会带回原对局并恢复局面。离席玩家的座位通常由游戏托管,归队后自动恢复操作。
- 支持观战的游戏可通过观战链接只读观看对局。
- 点击游戏内返回按钮回到大厅首页,直接关闭页面不影响对局状态。
充值
大厅顶栏点击充值进入充值页。充值前需完成实名认证,未实名时页面会先引导认证。
实名认证
填写真实姓名与身份证号提交。前端按国家标准校验证件号格式,通过后显示认证状态与证件尾号。身份信息仅用于合规校验,平台不以明文保存。
充值与到账
平台提供多档充值面额,兑换比例为 1 元兑换 100 金币,到账实时推送。
- 选择档位并确认,生成订单,页面展示订单号与状态。
- 完成支付。当前支付渠道为沙箱模拟支付,不发生真实扣款。
- 支付成功后金币自动到账,顶栏余额即时刷新。
订单状态包括待支付、支付中、已到账、已失败、已过期与已退款。未完成支付的订单不会扣款,支付结果异常时可稍后在充值记录中查看。
充值记录
充值页下方展示最近 10 条充值记录,包含档位、时间、订单号、金额与状态。
金币为单向获取的虚拟物品,不可兑回,见合规与账户安全。
个人中心
顶栏点击用户名进入个人中心,包含资料、成长、任务、记录与安全设置。
基本资料
昵称可修改,1–20 个字符,保存后游戏内下一局生效。头像为固定占位,暂不支持上传。
成长与福利
- 等级体系:游玩积累经验,等级与进度条随经验提升。
- 每日签到:7 天循环,每日奖励金币,当日签到当日领取。
- 每日任务:完成任务目标后领取金币奖励,页面显示完成进度。
统计与记录
- 统计:金币余额、交易笔数与游戏场次。
- 对局记录:最近 10 条,含游戏名、时间、胜平负结果与分数变化。
- 金币流水:最近 50 条,含描述、时间与金额方向。
账户与安全
| 功能 | 说明 |
|---|---|
| 当日充值限额 | 自主设定每日充值上限,只能下调 |
| 自我排除冷静期 | 可选 3、7、30、180 天,排除期内无法充值,也不能自行解除 |
| 账号注销 | 输入登录密码确认,注销后不可恢复,流水保留用于对账 |
通知中心
顶栏铃铛进入通知中心,交易与系统消息在此展示。
- 每页最多 50 条,展示标题、正文与时间。
- 未读消息带未读标记,点击即标记已读,也可一键全部已读。
- 顶栏铃铛显示未读数量,超过 99 显示为 99+。
合规与账户安全
使用范围
平台金币为虚拟物品,充值支付为沙箱模拟流程,不发生真实扣款,金币不可兑回法币,提现通道未开放。平台页面长期展示适度游戏、理性消费提示。
健康游戏工具
个人中心的账户与安全页提供两项自主工具。
- 当日充值限额:设定每日充值上限,一经下调不可自行调高。
- 自我排除冷静期:可选 3、7、30、180 天,排除期内无法充值,期内不能自行解除。
账号安全
- 密码找回仅通过注册邮箱的重置码流程,重置后所有设备强制下线。
- 玩家凭证不会下发到任何游戏页面。游戏只能获取一次性 WebSocket 票据,票据单次有效且 30 秒过期。
- 注销账号需密码二次确认,注销后流水按合规要求保留用于对账。
管理后台概览
管理后台位于 game.zihua-chen.cn/admin/,供平台运营与开发商使用。账号由平台创建,登录后按角色与权限控制可见页面与接口。开发商账号只能管理本开发商名下的游戏。
| 页面 | 用途 |
|---|---|
| 仪表盘 | 平台运行概览与待处理事项 |
| 开发商管理 | 开发商资料、账号状态与旗下资源 |
| 分类管理 | 大厅展示分类与游戏归属 |
| 游戏管理 | 游戏信息、发布版本与上下架状态 |
| 游戏审核 | 审查开发商提交的游戏版本,通过即发布 |
| 管理员账号 | 后台账号与角色授权 |
| 菜单管理 | 配置后台导航结构与入口排序 |
| 权限管理 | 按菜单维护接口权限点 |
| 角色管理 | 管理角色并分配权限 |
| 玩家管理 | 玩家账户、余额与交易流水查询处置 |
| 财务中心 | 充值订单、渠道对账、发放批次与风控处置 |
| 内容运营 | 大厅轮播、公告与推荐位维护 |
| 数据看板 | 平台总览、转化漏斗、留存与游戏健康度 |
| 券管理 | 金币券与补签卡的批次发放和启停 |
| 客服工单 | 玩家工单受理、回复、补偿与回合回放仲裁 |
| 审计日志 | 后台关键操作追踪 |
接入总览
本指南面向游戏开发商,说明如何将一款游戏接入本平台,并获得分发、联机、匹配、结算与运营数据能力。接入完成后,玩家在大厅内即可发现、进入并游玩你的游戏。完整 API 细节以仓库 docs/developer-guide.md 为准。
文档约定
- 必须:强制要求,不满足将无法通过审核或无法正常运行。
- 禁止:强制禁止,违反将导致提审被拒或运行被终止。
- 建议:推荐做法,平台以此为准设计,偏离需自行承担兼容风险。
平台能力
| 能力 | 说明 |
|---|---|
| 游戏分发 | 游戏上架后进入大厅游戏列表,支持分类、搜索、收藏与最近游玩 |
| 联机对战 | 平台提供 WebSocket 网关、会话管理、断线重连与在线状态 |
| 匹配系统 | quick 模式下平台自动撮合玩家并开局 |
| 房间系统 | room 模式下平台提供房间创建、房间码加入与等待室长轮询 |
| 观战 | 会话级只读观战,需游戏声明允许 |
| 托管结算 | 平台托管玩家余额,提供回合级原子结算、流水审计与实时余额推送 |
| 公平随机 | 提供 commit-reveal 公平随机协议,玩家可独立验证随机结果 |
| 内容审核 | 前端包与游戏逻辑包版本化提审,审核通过即发布,支持版本回滚 |
| 运营数据 | 后台提供对局数与金币消耗、奖励汇总 |
接入架构
游戏由游戏前端与游戏逻辑两部分组成,均由开发商开发、平台托管运行。
- 游戏前端是标准 Web 静态资源,以 iframe 方式嵌入玩家大厅页面运行。
- 游戏逻辑是一份 JavaScript 程序,提交到平台后由隔离运行时托管执行,负责全部权威玩法逻辑与结算。游戏逻辑不暴露任何网络接口,玩家无法绕过前端直接访问。
flowchart LR
subgraph BROWSER["玩家浏览器"]
L["平台大厅"] -- "iframe 嵌入" --> GF["游戏前端<br/>开发商静态页面"]
end
subgraph PLATFORM["平台"]
H["大厅宿主"] -- "postMessage<br/>连接配置与票据" --> GF
G["WebSocket 网关"] --> R["游戏逻辑运行时<br/>托管开发商代码"]
R -- "回合结算" --> W["钱包服务"]
S["静态资源托管<br/>存放游戏前端包"]
P["开放 API"]
end
GF -- "WebSocket 游戏事件" --> G
L -- "REST" --> P
DEV["开发商后台与 CI"] -- "提包、提审、查询" --> P
P -- "审核通过发布" --> S
P -- "审核通过发布" --> R
设计原则:
- 凭证不进游戏前端:游戏前端通过宿主协议向大厅索取一次性 WebSocket 票据,票据单次有效、30 秒过期。
- 权威在服务端:影响玩法与资金的操作均由游戏逻辑裁决,前端只负责表现。
- 资金平台托管:游戏逻辑只能通过平台回合接口发起扣款与奖励,结算原子完成并全程留痕。
接入流程
注册开发商账号 → 平台资质审核 → 登录开发商后台
→ 创建游戏,录入基础信息与联机参数
→ 开发游戏前端与游戏逻辑 → 本地联调自测
→ 提审,上传前端包并提交游戏逻辑包
→ 平台审核 ──驳回──▶ 按意见修改后重新提审
└──通过──▶ 自动发布上架 → 运营数据跟踪 → 版本迭代
| 阶段 | 责任方 | 说明 |
|---|---|---|
| 入驻审核 | 平台 | 开发商注册后为待激活状态,资质审核通过后开通登录 |
| 创建游戏 | 开发商 | 后台录入游戏基础信息与联机参数 |
| 开发联调 | 开发商 | 使用双 SDK 开发,本地联调环境完成自测 |
| 包审核 | 平台 | 对前端包与游戏逻辑包做安全与合规审核,首个版本通过即自动上架 |
| 版本发布 | 平台 | 审核通过立即全量发布,线上问题可回滚至任一已过审版本 |
入驻与创建游戏
以下接口路径均为相对路径,调用时以平台开放 API 域名为前缀,例如 https://game.zihua-chen.cn。
注册开发商
POST /api/portal/developer/register
{
"code": "acme",
"name": "ACME Interactive",
"description": "休闲游戏开发商",
"username": "acme_admin",
"email": "dev@acme.com",
"password": "******"
}
| 字段 | 说明 |
|---|---|
| code | 开发商标识,2–30 位字母、数字或下划线,全局唯一 |
| name | 开发商名称,不超过 100 字符 |
| description | 选填,不超过 500 字符 |
| username | 后台登录账号,3–50 位 |
| 联系邮箱 | |
| password | 登录密码,至少 6 位 |
注册成功后账号为待激活状态,平台资质审核通过后即可登录。code、用户名或邮箱重复返回 409。
登录开发商后台
POST /api/admin/login
{ "username": "acme_admin", "password": "******" }
响应包含访问令牌、刷新令牌、账号信息与权限列表。后续所有管理接口以 Authorization: Bearer <accessToken> 方式鉴权。访问令牌过期返回 401 与错误码 401002,需用刷新令牌换新。
账号能力边界
- 开发商账号只能创建、修改、提审本开发商名下的游戏,越权返回 403。
- 玩家管理、全量游戏列表、包审核与游戏上下架属平台职能,开发商无权限。
- 开发商被停用后,登录与所有游戏操作均被拒绝,对应错误码为 403104 与 403301,直至平台重新激活。
创建游戏
POST /api/admin/game
{
"title": "示例游戏",
"description": "三人合作对战示例",
"categoryId": 3,
"thumbnailUrl": "https://example.com/cat.png",
"sessionMode": "quick",
"minPlayer": 3,
"maxPlayer": 3,
"allowSpectate": false
}
| 字段 | 说明 |
|---|---|
| title | 必填,不超过 255 字符,同一开发商内唯一 |
| description | 必填,不超过 5000 字符,展示在大厅 |
| categoryId | 必填,平台分类,通过 GET /api/admin/category 查询 |
| thumbnailUrl | 选填,不超过 2000 字符,大厅卡片图 |
| sessionMode | quick 为自动匹配,room 为房间邀请,缺省 quick |
| minPlayer | 1 到 100,且不大于 maxPlayer |
| maxPlayer | 会话人数上限 |
| allowSpectate | 是否允许观战 |
会话模式选择:
| 模式 | 行为 | 适用 |
|---|---|---|
| quick | 玩家点击开始后进入平台匹配队列,会话人数达到 minPlayer 即自动就绪并开局 | 对战、牌桌类 |
| room | 房主创建房间获得 6 位房间码,其他玩家凭码加入等待室,房主满足人数后开局 | 好友邀请、私密局 |
平台只在人数达到 minPlayer 时自动开局,人数不足时的补位或托管策略由游戏逻辑自行实现,可参考示例工程。除特殊玩法外建议 minPlayer 与 maxPlayer 相等,开局人数最确定、等待最短。
创建成功后游戏为 draft 状态,完成开发并提审通过后自动变为 approved 并上架。
游戏逻辑开发
游戏逻辑运行在平台的隔离运行时中,使用 TypeScript 或 JavaScript 编写,TypeScript 编译目标为 ES5,最终构建为单文件 IIFE bundle 提交。
双 SDK
| SDK | 运行位置 | 用途 |
|---|---|---|
@game-platform/backend-sdk | 平台游戏逻辑运行时 | 提供生命周期基类与 platform API 类型,编写权威玩法逻辑 |
@game-platform/frontend-sdk | 游戏前端 | 封装宿主协议、WebSocket 连接、重连与事件分发 |
生命周期
继承 GameLifecycle 并重写需要的钩子,最后调用 registerGame() 注册:
import { GameLifecycle, registerGame } from '@game-platform/backend-sdk'
class MyGame extends GameLifecycle {
// 会话准备就绪,初始化牌桌与座位,此时玩家尚未进入对局。
onInit(): void {}
// 玩家进入或重连,可用于落座、夺回被托管的座位。
onPlayerJoin(playerId: string, displayName: string): void {}
// 玩家最后一条连接断开,成员资格保留,建议将座位交给托管逻辑。
onPlayerLeave(playerId: string): void {}
// 玩家玩法动作,仅在会话进入 playing 后被调用。
onAction(playerId: string, action: string, payload: any): void {}
// 平台状态快照拉取,首次进入、重连、页面恢复可见均可触发。
// 必须用 platform.sendTo 回应本人全量状态。
onSync(playerId: string): void {}
// 会话结束,做最终结算与收尾。
onFinish(): void {}
}
registerGame(new MyGame())
调用时序:
会话准备就绪: onInit()
玩家进出: onPlayerJoin 与 onPlayerLeave 循环触发
会话进入 playing: 动作开始受理,循环:
onAction(playerId, action, payload)
└─ broadcast 或 sendTo 下发事件
onSync(playerId),状态拉取随时可能发生
会话结束: onFinish()
注意:
- onInit 阶段不要假设玩家已落座;onAction 之前一定已收到对应玩家的 onPlayerJoin。
- onSync 是玩家对账的唯一通道,必须实现。断线重连、切换标签页、补发状态都依赖它。
- 运行时还支持一个可选的全局
onStart():会话进入 playing 时若已定义则被调用,适合满员即开局的玩法触发第一手动作。建议在模块顶层直接定义该函数,不经过registerGame。 - 建议所有钩子无副作用地可重入,同一玩家重连会再次触发 onPlayerJoin。
platform API
platform 为运行时注入的全局对象,完整清单如下。
通信
| API | 说明 |
|---|---|
broadcast(event, data?) | 向会话内所有成员含观战者广播事件 |
sendTo(playerId, event, data?) | 向指定玩家定向发送,非成员静默忽略 |
getPlayers() | 返回玩家名册,内容为初始化时的快照,后续加入者经 onPlayerJoin 通知 |
isPlayerOnline(playerId) | 判断玩家当前是否有存活连接,用于区分离席与后台标签页 |
finishSession() | 在干净边界结束整个会话并释放房间,例如一手结束且无人在线 |
reopenSession() | 对局刚结束且有空位时,将会话退回等待态,允许新玩家凭码加入 |
状态
| API | 说明 |
|---|---|
getGameState(key) / setGameState(key, value) | 会话级键值存储,会话结束即销毁,不持久化 |
random() | 密码学安全随机数,取值区间为 0 到 1 左闭右开 |
log(...args) | 服务端日志,用于排障 |
setTimeout / setInterval / clearTimer | 定时器,会话结束时统一清理 |
结算,详见资金接入:
| API | 说明 |
|---|---|
beginRound() / endRound() | 开启与提交业务回合,分别返回回合 ID 与提交结果 |
deductBalance(playerId, amount, desc?) | 回合内登记扣款 |
rewardBalance(playerId, amount, desc?) | 回合内登记奖励 |
getBalance(playerId) | 本地缓存余额,可能滞后,禁止用于扣款前预检 |
getBalanceVersion(playerId) | 本引擎最近一次提交的余额版本号,随结果下发,用于客户端丢弃乱序推送 |
lastRoundError() | 读取最近一次回合提交失败原因 |
getRoundId() | 当前回合 ID,无回合时为空串 |
公平随机,详见资金接入:
| API | 说明 |
|---|---|
beginFairRound(seedId) | 生成保密种子并向全员广播其 SHA-256 承诺,返回承诺值 |
getFairRandom(seedId, index) | 从公平种子派生第 index 个随机数 |
revealFairRound(seedId) | 结算后公开种子,全员可复算随机过程 |
运行约束
以下为运行时的硬性约束,提审与运行均按此校验:
| 约束 | 值 | 说明 |
|---|---|---|
| bundle 大小 | ≤ 512 KiB | 超出拒绝提审 |
| 字符编码 | 必须 UTF-8 | |
| 禁用能力 | eval、动态 import、require、process.env、Function 构造器、fetch、XMLHttpRequest、WebSocket、DOM 与 BOM 全局 | bundle 中出现 eval、import、require、process.env、this.constructor 直接拒审,运行环境中上述全局均不可用 |
| 随机数 | 必须使用 platform.random 或公平随机接口 | Math.random 已被替换为密码学安全源,涉及资金的逻辑禁止自行引入可预测随机 |
| 单次回调耗时 | ≤ 2 秒 | 任一钩子或定时器回调超时,会话立即进入 error 终态并全员收到错误事件,必须控制搜索与 AI 算法复杂度,采用节点预算加兜底策略 |
| 回合闭合 | 必须在钩子返回前调用 endRound | 钩子返回时仍有未闭合回合,本轮全部资金操作作废并产生会话错误 |
| 状态存储 | 会话内存级 | setGameState 不落盘,需要跨局保留的数据不得依赖它 |
建议开局阶段的状态全部通过事件广播下发,事件携带完整公共快照,使观战者与中途进入者无需补发即可重建局面。超大快照随每条事件全量携带的成本远低于维护增量补发逻辑。
游戏前端开发
游戏前端运行在大厅 iframe 中,是常规 Web 工程,构建产物打包为 zip 提交。通过前端 SDK 完成全部接入:
import { GameClient, GameLifecycle, requestHostGameConfig, notifyGameExit } from '@game-platform/frontend-sdk'
class MyGameView extends GameLifecycle {
onConnect() { /* WS 已建立 */ }
onSessionJoin(sessionId, playerCount) { /* 已加入会话 */ }
onStart(data) { /* 进入对局,开始受理操作 */ }
onEvent(action, payload) { /* 游戏自定义事件 */ }
onFinish(data) { /* 终局展示 */ }
onError(error) { /* 错误提示 */ }
}
// 1. 向大厅宿主索取连接配置,自动处理 origin 校验
const config = await requestHostGameConfig()
// 2. 创建客户端并连接,SDK 每次连接与重连自动索取一次性票据
const client = new GameClient({
url: config.wsUrl,
gameId: config.gameId,
ticketProvider: config.requestTicket,
sessionId: config.sessionId,
roomCode: config.roomCode,
spectate: config.spectate,
})
client.use(new MyGameView())
await client.connect()
// 3. 发送玩法动作,对局开始后
client.sendAction('move', { from: 12, to: 34 })
// 4. 游戏自身返回按钮触发时,通知大厅返回
notifyGameExit()
禁止在游戏前端内自行实现返回大厅逻辑,统一使用 notifyGameExit() 由大厅决定返回方式。
GameClient 其余方法:ready() 就绪、start() 开始,房间模式房主使用、finish() 结束、sync() 拉取权威快照、disconnect() 断开、isConnected() 查询连接状态。
连接策略:SDK 默认自动重连,间隔 3 秒,最多 5 次,重连耗尽触发 onReconnectFailed。建议保留默认值,页面重新可见时调用 client.sync() 对账,替代任何轮询。
平台系统消息
除游戏自定义事件外,平台经 WebSocket 下发系统级消息,未显式处理的统一进入 onEvent:
| 消息 | 载荷 | 处理要求 |
|---|---|---|
session:started | sessionId, playerCount | 进入对局并开启操作,已映射到 onStart |
session:finished | sessionId | 终局,已映射到 onFinish |
session:member_left | sessionId, playerId | 有玩家离席,建议展示等待归队或托管中 |
session:timeout | sessionId | 会话超时结束,展示对应提示 |
error | message | 平台或游戏逻辑错误,展示并允许重试 sync |
wallet:balance | balance, version, source | 余额实时推送,必须按 version 单调守卫,只应用比本地新的版本,防止乱序回退 |
wallet:error | requestId, roundId, error | 结算被拒,展示具体原因 |
fair:commitment | seedId, commitment | 公平随机承诺,存档供终局验证 |
fair:reveal | seedId, seed, commitment, draws | 种子揭示,校验 SHA-256('fair-seed:' + seed) 与承诺一致后可复算随机过程 |
建议游戏结果载荷中携带游戏逻辑写入的 roundId 与 getBalanceVersion() 版本号。客户端应用结果时同步抬高余额水位,使早于结果的余额推送被正确丢弃。
事件与协议规范
多数场景下前端 SDK 已完成协议封装,本章供自研客户端与深度排障使用。
WebSocket 消息信封
平台使用统一信封,游戏开发通常无需直接操作,SDK 已封装:
{
"type": "event",
"action": "poker:deal",
"payload": { },
"timestamp": 1769000000000,
"seq": 42,
"playerId": "…",
"gameId": "…",
"sessionId": "…"
}
- type 分为 event、action、system 三类。客户端只发送 action 与 system,seq 用于服务端去重,重发同一动作不会重复执行。
- 单帧上限 64 KiB,禁止在单个动作或广播载荷中携带超大对象。
命名规范
- 游戏自定义事件必须使用
<游戏前缀>:<事件名>命名空间,例如xq:move,避免与其他游戏及平台消息冲突。 - 玩家动作必须使用小写 snake_case,例如 move、draw、discard。
- 载荷字段统一 snake_case。
- 禁止复用 session、wallet、fair、error、state:sync 等平台保留前缀与名称。
连接建立
1. 携带玩家访问令牌换取一次性票据:
POST /game/ws/ticket Authorization: Bearer <accessToken>
→ { "ticket": "…" } 票据单次有效,30 秒过期
2. 建立 WebSocket:
GET /game/ws?ticket=<ticket>&gameId=<gameId>
[&sessionId=<sessionId> | &roomCode=<roomCode>]
[&spectate=1]
连接成功后服务端下发 session:joined,观战则下发 session:spectating。
会话状态机
created → waiting → ready → playing → finished
↘ error / timeout 终态
前端通过 session:state 消息与 onStateChange 感知状态流转。玩法动作仅在 playing 状态被受理。
房间模式 REST
| 接口 | 说明 |
|---|---|
POST /game/:id/room | 创建房间,响应含 sessionId、roomCode、minPlayer、maxPlayer、reused。同一房主已有房间则复用,reused 为 true |
GET /game/:id/room/:roomCode?after=<gen> | 等待室状态长轮询,响应含 state、owner、players、gen、isOwner。gen 变化立即返回,最多挂起 25 秒 |
POST /game/:id/room/:roomCode/start | 房主开局,要求人数达标,响应 state 为 playing |
客人加入不走 REST,直接以 roomCode 参数建立 WebSocket。房间码为 6 位大写字符,已去除易混淆字符。
断线重连与离席
| 机制 | 行为 |
|---|---|
| 同账号多端 | 允许多端多标签并存,各自独立连接 |
| 断线重连 | 携相同 sessionId 或 roomCode 重连即回到会话,随后通过 sync 拉取权威快照,座位仍保留 |
| 离席判定 | 最后一条连接断开且无其他在线连接时触发 onPlayerLeave,多标签场景平台已去重。网络中断等异常断开最迟 2 分钟内判定 |
| 会话生命周期 | 会话最长存活 2 小时,超时进入 timeout 终态并广播 session:timeout |
建议游戏逻辑在 onPlayerLeave 时将离席座位转为托管而非立即终局,在 onPlayerJoin 时归还控制权,可获得最好的断线体验。
资金接入
回合模型
一切资金变动必须包裹在显式回合中。平台不会隐式创建回合,无活跃回合时扣款与奖励登记返回 false,且不产生任何流水。
游戏前端 ──动作: settle {fee:100}──▶ 游戏逻辑
beginRound 得到回合 ID
deductBalance 扣除 100
按玩法规则计算奖励
rewardBalance 发放奖励
endRound 提交 ──▶ 平台钱包,整回合原子生效 ──▶ 余额推送 wallet:balance
游戏逻辑 ──广播 game:settled {roundId, balanceVersion}──▶ 游戏前端
标准代码模式:
onAction(playerId: string, action: string, payload: any) {
if (action !== 'settle') return
const roundId = platform.beginRound()
if (!roundId) return // 已有未闭合回合,忽略本次
let committed = false
try {
platform.deductBalance(playerId, payload.fee, `settle ${roundId}`)
const prize = this.rewardOf(playerId) // 按玩法规则计算奖励,使用公平随机
if (prize > 0) platform.rewardBalance(playerId, prize, 'settle reward')
} finally {
committed = platform.endRound() // 无论中途何种分支,回合必须闭合
}
if (!committed) {
// 提交失败:读取失败原因,向玩家说明,并回滚本局状态
platform.sendTo(playerId, 'game:error', { reason: platform.lastRoundError() })
return
}
platform.broadcast('game:settled', {
player_id: playerId,
round_id: roundId,
balance_version: platform.getBalanceVersion(playerId),
})
}
规则:
- 必须遵循 beginRound、登记、endRound 的顺序。endRound 返回 true 之前,不得视任何扣款与奖励为生效。
- 必须处理 endRound 返回 false 的分支:读取 lastRoundError,恢复对局前状态,并让玩家知晓。
- 金额为 64 位整数金币。小数会被静默截断,NaN、Infinity 与非正数直接拒绝。禁止依赖超过 2 的 53 次方的数值,那是 JS 的精度边界。
- 一个引擎同一时刻只允许一个活跃回合。批量结算类玩法在一回合内逐人登记即可。
- 回合提交为整组原子:任一操作失败,整回合全部回滚,并逐操作下发 wallet:error。设计玩法时避免跨回合的半结算状态。
幂等与安全
- 玩家动作重复发送时,平台按玩家与动作序号去重,动作内产生的资金操作自动幂等,不会重复记账。
- 回合 ID 只能由 beginRound 在服务端生成。禁止接受前端载荷中的任何回合标识作为结算依据。
- 全部回合操作落审计账,含回合表与逐操作表,可供平台回溯与对局回放,每回合最多录制 500 条广播事件。
余额显示
游戏内余额显示的唯一权威是平台的 wallet:balance 版本化推送,配合结算结果中的版本号使用:
- 收到
wallet:balance时,仅当 version 大于本地水位才更新显示并抬高水位。 - 收到结算结果时,应用结果携带的 balance_version,将水位至少抬到该值,丢弃此前积压的旧推送。
- 页面恢复可见或重连后,调用
client.sync()拉取权威快照对账。
禁止轮询余额接口,平台推送链路已覆盖对局、充值、平台发放、管理调账等全部余额变更来源。
platform.getBalance() 返回引擎本地缓存,可能滞后于权威值。必须将其用于展示与体验优化,禁止用于扣款前预检。余额不足应在 endRound 之后经 lastRoundError 统一处理,平台结算时的校验才是权威。
公平随机
涉及随机结果的玩法,例如洗牌、随机事件判定,必须使用平台的 commit-reveal 公平随机接口,使玩家可以独立验证随机过程未被篡改:
const seedId = 'fair:' + roundId
const commitment = platform.beginFairRound(seedId) // 先广播承诺,再消费任何随机数
if (!commitment) { /* 熵源失败,取消本局 */ }
const point = Math.floor(platform.getFairRandom(seedId, 0) * DECK_SIZE)
// … 按玩法规则结算 …
platform.revealFairRound(seedId) // 揭示种子,玩家可复算
验证算法,玩家侧可完整复算:
- 承诺值为
SHA-256("fair-seed:" + seed)的十六进制串。 - 派生随机数取
SHA-256(seed + ":" + index)结果的前 53 位除以 2 的 53 次方,得到 0 到 1 左闭右开区间上的均匀分布。同一 seedId 可派生多次独立抽取。 - 时序要求:必须在消费任何随机数之前调用 beginFairRound,承诺先行;结算完成后、回合闭合前调用 revealFairRound。
- 每个引擎实例最多保存 64 个未揭示种子,先进先出淘汰。建议一回合一种子,用后即揭示。
依赖随机结果的玩法在提审时应按平台要求申报玩法参数,上线后平台持续核对实际运行数据,偏差过大将触发核查。
提交与审核
游戏包构成
一个可提审的游戏版本由两部分组成,可只更新其中之一:
| 组成 | 形式 | 上传方式 |
|---|---|---|
| 前端包 | zip 压缩包,构建产物 | 先上传获得 URL,再随提审单提交 |
| 游戏逻辑包 | 单文件 JS bundle 全文 | 随提审单以字符串提交 |
前端包硬性要求:
- zip 原始大小不超过 25 MB,解压后总大小不超过 75 MB,文件数不超过 2000。
- 必须在 zip 根目录包含 index.html 入口文件。
- 必须为纯静态资源,不依赖任何服务端能力。SPA 路由请使用 hash 模式。
游戏逻辑包硬性要求见游戏逻辑开发的运行约束一节。
上传前端包
POST /api/admin/game/:id/frontend-bundle
Content-Type: multipart/form-data
version: 1.0.0
file: dist.zip
响应:
{
"frontendBundleUrl": "https://example.com/game-frontend/<gameId>/1.0.0/<uuid>/index.html",
"bundleHash": "<zip 字节的 SHA-256 十六进制串>"
}
bundleHash 与 URL 需要在提审单中原样带回,作为该版本内容的存证。
提交审核
POST /api/admin/game/:id/package
{
"version": "1.0.0",
"frontendBundleUrl": "https://example.com/…",
"bundleHash": "…",
"backendJsBundle": "…"
}
| 字段 | 说明 |
|---|---|
| version | 必填,1–50 字符,建议语义化版本 |
| frontendBundleUrl | 首次提审必填,更新时留空表示沿用上一版本 |
| bundleHash | 与前端包对应 |
| backendJsBundle | bundle 全文,首次提审必填,更新时留空表示沿用上一版本 |
增量提交规则:
- 首次提审必须同时包含前端包与游戏逻辑包。
- 后续版本可只提交变更的一侧,未提交侧自动继承上一已提交版本,并在包记录中标注来源版本,便于审核聚焦变更内容。
- 两侧均未变更的提交会被拒绝,错误码 400332。
提交后版本进入 pending_review,响应为完整包记录,含包 ID、状态与继承标记,可通过 GET /api/admin/game/:id/package 查询全部历史版本。
依赖随机结果的玩法需按平台要求申报玩法参数,具体以入驻协议为准,提交前与平台确认。
审核范围
| 审核项 | 说明 |
|---|---|
| 安全 | 游戏逻辑包静态扫描禁用能力,前端包解压安全,防范路径穿越与压缩炸弹 |
| 合规 | 无外联、无第三方广告与支付、无诱导跳转,玩法参数申报合理 |
| 资金 | 回合模型使用正确,无绕过回合的资金操作,失败分支处理完整 |
| 体验 | 断线重连、状态同步、离席处理、错误提示完整可用 |
| 联机 | 人数配置与会话模式同玩法匹配,匹配参数合理 |
首个版本审核通过后立即自动发布,游戏转为 approved 并出现在大厅,无需另行上架操作。审核意见与驳回原因在包记录的 reviewComment 字段返回。
版本更新与回滚
- 更新:重复上传与提审流程,审核通过即全量替换线上版本。进行中的对局继续使用旧版本直至结束,新对局使用新版本。
- 回滚:任一已过审版本可随时切换回线上,接口为
POST /api/admin/game/:id/package/:pid/switch,目标包须为 approved 状态。游戏归档状态下不允许切换版本。
平台侧发布脚本
平台运营代发布示例游戏时,可用仓库内脚本一键完成上传、提审与过审发布:
ADMIN_API=https://game.zihua-chen.cn ADMIN_USER=admin ADMIN_PASS=… \
./scripts/publish-game.sh <gameId> <version> <前端dist目录> <backend/dist/game.js>
注意过审即发布,逻辑包必须随包重提,只传前端包的版本不会更新服务端逻辑。
本地联调
平台在仓库 examples/ 目录提供完整示例游戏,每个示例都包含 backend、frontend 与 devserver 三个子目录。devserver 是本地联调环境,模拟真实平台的宿主协议、匹配与会话行为。SDK 完整源码见SDK 源码附录。
构建与启动
# 1. 构建双 SDK,首次执行
cd sdks/game-backend-sdk && npm install && npm run build
cd sdks/game-frontend-sdk && npm install && npm run build
# 2. 构建示例游戏,GAME_DIR 换成任一示例的目录名
cd examples/$GAME_DIR/backend && npm install && npm run build && npm test
cd examples/$GAME_DIR/frontend && npm install && npm run build
# 3. 启动本地联调环境,mock 宿主、mock 匹配与 WebSocket
cd examples/$GAME_DIR/devserver && npm install && npm start
# 多开标签页即多玩家,可完整验证匹配、开局、离席托管、观战与结算
自测要点
提审前请至少覆盖以下场景:
- 单人开局、满员自动开局、人数不足补位。
- 断线重连后状态恢复,即 onSync 路径。
- 离席托管与归队夺回。
- 余额不足时 endRound 失败分支与玩家提示。
- 公平随机承诺与揭示时序。
- 定时器在会话结束时无残留。
运营数据与错误处理
运营数据
开发商后台提供按游戏的运营汇总:
GET /api/admin/developer/me/stats
{ "items": [
{ "gameId": "g_abc123",
"title": "示例游戏",
"status": "approved",
"rounds": 1234,
"betCoins": 4567890,
"payoutCoins": 4321000 }
]}
| 字段 | 说明 |
|---|---|
| rounds | 结算成功的回合数 |
| betCoins | 消耗金币总额统计 |
| payoutCoins | 奖励金币总额统计 |
数据来自平台回合审计账,与结算流水同源,单位一律为整数金币。建议每日关注消耗与奖励的变化趋势,提前发现玩法参数问题。
接口错误契约
所有开放接口遵循统一错误契约:
- 错误类别即 HTTP 状态码,400 参数错误、401 未认证、403 无权限、404 资源不存在、409 冲突、429 限流、5xx 服务端错误。
- 每个响应带
X-Error-Code头,成功为 0,失败为 6 位业务错误码。 - 失败时另带
X-Error-Message头,为 URL 编码的中文文案,响应体为空。 - 判断逻辑一律以错误码为准,禁止解析文案文本。
6 位错误码结构为 SSS DD Q:前 3 位为 HTTP 状态,第 4 位为业务域,0 通用、1 账号、2 玩家、3 开发商与游戏内容、4 资金、5 对局,末 2 位为域内序号。
开发商高频错误码
| 错误码 | 文案 | 处理建议 |
|---|---|---|
401002 | 访问令牌已过期 | 用刷新令牌换新后重放 |
403104 / 403301 | 开发商账号已停用 | 联系平台 |
400307 | 上架前必须先上传前端包 | 先完成前端包上传 |
400308 | 上架前必须先通过至少一个版本的审核 | 先提审 |
400328 | 上传文件校验失败 | 对照包规范自查 |
400331 | 首次提审需同时上传前端包和后端包 | 补齐两侧 |
400332 | 本次提交未包含新的包内容 | 至少更新一侧再提交 |
400309 | 已归档游戏不能切换版本 | 先恢复上架 |
400312 | 只能使用已过审的版本 | 仅可切换 approved 包 |
400511 | 余额不足 | 游戏内提示余额不足并引导充值 |
500501 | 游戏脚本执行超时 | 优化逻辑耗时,单次回调上限 2 秒 |
平台限制与自查清单
平台限制汇总
| 项 | 限制 |
|---|---|
| 游戏逻辑 bundle | ≤ 512 KiB,UTF-8,禁用 eval、动态 import、require、process.env、this.constructor |
| 前端包 | zip ≤ 25 MB,解压 ≤ 75 MB,≤ 2000 文件,根目录含 index.html |
| 单次脚本回调 | ≤ 2 秒,超时会话进入 error 终态 |
| WS 单帧 | ≤ 64 KiB |
| WS 票据 | 单次有效,30 秒过期 |
| 金额 | 64 位整数金币,必须大于 0 |
| 回合提交 | 提交超时 15 秒,整回合原子,失败全回滚 |
| 未揭示公平种子 | 每引擎 64 个,先进先出 |
| 对局回放 | 每回合 ≤ 500 条广播事件 |
| 会话存活 | 最长 2 小时 |
| 离席判定 | 正常断开即时,异常断开最迟 2 分钟 |
| 广播、状态与定时器 | 无硬性配额。建议控制广播频率与快照体积,定时器在钩子内用完即清 |
提审自查清单
- 游戏逻辑 bundle 不超过 512 KiB,无禁用关键字,兼容 ES5
- 前端包 zip 根目录含 index.html,使用 hash 路由,体积达标
- 所有资金流程包裹在 beginRound 与 endRound 之间,失败分支已处理并向玩家展示失败原因
- 随机类玩法使用公平随机,承诺先于抽取、结算后揭示,玩法参数已按平台要求申报
- onSync 已实现,重连与切换标签页后可完整恢复
- 离席处理为座位托管或明确终局,不产生悬挂等待
- 余额显示按 version 单调守卫,无轮询
- 事件带游戏命名空间前缀,无平台保留名冲突
- 本地联调环境全流程自测通过,覆盖匹配、开局、结算、重连与观战
常见驳回原因
- 游戏逻辑依赖运行环境不提供的能力,如 fetch、DOM、eval。
- 资金操作未闭合回合,或以 getBalance 预检代替结算失败处理。
- 随机类玩法未使用公平随机,或时序错误,先取随机数后承诺。
- 前端缺少 index.html,或使用 history 路由导致刷新 404。
- 断线重连后无法恢复对局,未实现 onSync。
- 算法在复杂局面下超过 2 秒回调上限。
SDK 源码附录
本附录收录双 SDK 的完整 TypeScript 源码,构建文档时直接从仓库 sdks/ 目录嵌入,与代码保持同步。SDK 的构建方式见本地联调。
game-backend-sdk
运行在平台游戏逻辑运行时中的 SDK,提供生命周期基类与 platform API 类型定义。
package.json
{
"name": "@game-platform/backend-sdk",
"version": "0.1.0",
"description": "Game Backend SDK — event-driven game logic for goja runtime (ES5 target)",
"type": "module",
"main": "./dist/game-backend-sdk.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/game-backend-sdk.js",
"types": "./dist/index.d.ts"
}
},
"files": ["dist"],
"scripts": {
"build": "vite build && tsc --declaration --emitDeclarationOnly --outDir dist",
"dev": "vite build --watch"
},
"devDependencies": {
"typescript": "~5.7.0",
"vite": "^6.0.0"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "ES5",
"module": "ES2015",
"moduleResolution": "bundler",
"declaration": true,
"declarationDir": "./dist",
"emitDeclarationOnly": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"lib": ["ES5"]
},
"include": ["src"]
}
vite.config.ts
import { defineConfig } from 'vite'
import { resolve } from 'path'
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'GameBackendSDK',
formats: ['es'],
fileName: () => 'game-backend-sdk.js',
},
rollupOptions: {
output: {
exports: 'named',
},
},
outDir: 'dist',
emptyOutDir: true,
target: 'es2015',
minify: false,
},
})
src/index.ts
入口文件:导出公共 API,并把生命周期钩子挂接到运行时注入的全局对象。
// Game Backend SDK — Engine entry point
import type { PlatformAPI } from './api';
import { EventEmitter } from './events';
import { GameLifecycle } from './lifecycle';
export type { PlatformAPI };
export { EventEmitter, GameLifecycle };
// Access the goja-injected platform global.
declare const platform: PlatformAPI;
/**
* Register a game instance. The engine will call lifecycle methods automatically.
*/
export function registerGame(game: GameLifecycle): void {
(globalThis as any).onInit = function () { game.onInit(); };
(globalThis as any).onAction = function (playerId: string, action: string, payload: any) {
game.onAction(playerId, action, payload);
};
(globalThis as any).onSync = function (playerId: string) {
game.onSync(playerId);
};
(globalThis as any).onFinish = function () { game.onFinish(); };
(globalThis as any).onPlayerLeave = function (playerId: string) {
if (game.onPlayerLeave) game.onPlayerLeave(playerId);
};
(globalThis as any).onPlayerJoin = function (playerId: string, displayName: string) {
if (game.onPlayerJoin) game.onPlayerJoin(playerId, displayName);
};
platform.log('Game registered successfully');
}
src/lifecycle.ts
GameLifecycle 基类,游戏逻辑继承并重写需要的钩子。
// Game Backend SDK — Lifecycle base class
import { EventEmitter } from './events';
/**
* Base class for game backend logic.
* Game developers extend this and override lifecycle methods.
*/
export abstract class GameLifecycle extends EventEmitter {
/**
* Called when the game engine initializes.
* Set up initial game state here.
*/
onInit(): void {}
/**
* Called when a player performs an action.
*/
onAction(playerId: string, action: string, payload: any): void {}
/**
* Called when a session member (player or spectator) pulls an authoritative
* state snapshot via the platform `state:sync` system action — first entry,
* reconnect, tab refocus, or error recovery. Unlike onAction this fires in
* any live session state (not just Playing). Push the caller's full state
* with platform.sendTo(playerId, ...).
*/
onSync(playerId: string): void {}
/**
* Called when the game finishes.
* Compute results and reward players here.
*/
onFinish(): void {}
/**
* Called when a member's last connection drops (genuine leave — the platform
* dedupes multi-tab/multi-device connections via presence). The member stays
* on the roster; a reconnect re-fires onPlayerJoin for the same ID.
* Games typically hand the vacated seat to a bot until they return.
*/
onPlayerLeave?(playerId: string): void {}
/**
* Called when a member (re)gains a live connection or joins the session:
* new joiners and reconnecting members both fire this. Games can restore
* human control of a bot-taken-over seat here.
*/
onPlayerJoin?(playerId: string, displayName: string): void {}
}
src/api.ts
PlatformAPI 类型定义,即运行时注入的 platform 全局对象的完整契约。
// Game Backend SDK — Public API
// This declares the global `platform` object injected by the goja bridge.
export interface BalanceOptions {
/** Optional ledger description written to player_transaction.description. */
description?: string;
}
export interface PlatformAPI {
/** Send an event to all players in the session. */
broadcast(event: string, data?: any): void;
/** Send an event to a specific player. */
sendTo(playerId: string, event: string, data?: any): void;
/** Get a value from the game state. */
getGameState<T = any>(key: string): T | undefined;
/** Store a value in the game state. */
setGameState(key: string, value: any): void;
/**
* Get the session roster with display names: [{id, name}]. Names are minted
* from each player's signed JWT at ticket time and flow through the session
* roster — no lookups. `name` is "" when unknown: fall back to your own ID
* rendering.
*/
getPlayers(): Array<{ id: string; name: string }>;
/**
* Whether the player currently holds any live WebSocket connection to this
* session (pure presence lookup — no IO from the engine's perspective).
* Bots and never-seen IDs are online by convention; use it to distinguish
* a vacated seat from a backgrounded tab.
*/
isPlayerOnline(playerId: string): boolean;
/**
* Conclude the session at a clean point (e.g. the current hand just ended
* and every human has left): the platform finishes the session, releases the
* room, and stops the engine. Intended to be called at most once, from a
* game-side "no players left" decision — never mid-hand.
*/
finishSession(): void;
/**
* Reopen a session whose current hand just ended with vacated seats: the
* platform transitions the session back to the joinable Waiting state so
* players can join by room code again and the game can start the next hand
* when the seats are refilled.
*/
reopenSession(): void;
/** Generate a random float between 0 and 1. */
random(): number;
/**
* Begin a verifiable (commit-reveal) fair round: the platform mints a secret
* seed and broadcasts only its SHA-256 commitment to players BEFORE any draw.
* Returns the commitment hash (empty string on entropy failure).
* Pair with getFairRandom(seedId, i) for draws and revealFairRound(seedId)
* after settlement so players can recompute every number.
*/
beginFairRound(seedId: string): string;
/** Derive the index-th reproducible number in [0,1) from a fair seed. Returns false for unknown seeds. */
getFairRandom(seedId: string, index: number): number | false;
/** Broadcast the revealed seed + commitment + draw count for players to verify. Returns false for unknown seeds. */
revealFairRound(seedId: string): { seed: string; commitment: string; draws: number } | false;
/** Log a message (visible in server logs). */
log(...args: any[]): void;
/** Schedule a one-shot callback after a delay (ms). Returns timer ID. */
setTimeout(callback: () => void, delay: number): number;
/** Schedule a repeating callback. Returns timer ID. */
setInterval(callback: () => void, interval: number): number;
/** Cancel a timer by its ID. */
clearTimer(id: number): void;
/** Begin an explicit business round with a platform-generated opaque round ID. Returns empty string if a round is already active. */
beginRound(): string;
/** Commit and end the active business round. Returns false when no round is active, arguments are provided, or settlement fails. */
endRound(): boolean;
/** Get the currently active round ID. Empty string means no explicit round is open. */
getRoundId(): string;
/**
* Get the wallet's rejection reason for the round that just failed to commit
* (read after endRound() returned false), e.g. "insufficient balance".
* Empty string when the last round committed successfully or no round ran.
* Surface it to the player instead of guessing from endRound's bare false.
*/
lastRoundError(): string;
/** Queue a balance deduction in the active round. Final settlement happens in endRound(). */
deductBalance(playerId: string, amount: number, options?: string | BalanceOptions): boolean;
/** Queue a balance reward in the active round. Final settlement happens in endRound(). */
rewardBalance(playerId: string, amount: number, options?: string | BalanceOptions): boolean;
/**
* Queue one seat's outcome for the active round (对局记录数据源). Requires
* an open round (beginRound); rows persist when endRound commits and are
* idempotent per (round, player). result: 'win' | 'lose' | 'draw'.
* scoreDelta is the player-visible score/coin change; meta (optional,
* <=255 chars) carries free-form context e.g. '炸弹×2' or '番型:清一色'.
*/
recordResult(playerId: string, result: 'win' | 'lose' | 'draw', scoreDelta?: number, meta?: string): boolean;
/** Get the current cached balance of a player. */
getBalance(playerId: string): number;
/**
* Get the monotonic version counter of the player's last committed balance
* (0 when unknown). Include it in result payloads so game clients can reject
* out-of-order balance pushes that predate the result.
*/
getBalanceVersion(playerId: string): number;
}
declare global {
const platform: PlatformAPI;
}
src/events.ts
事件分发器。
// Game Backend SDK — Event emitter for game logic
interface EventHandler {
(...args: any[]): void;
}
export class EventEmitter {
private handlers: Record<string, EventHandler[]> = {};
on(event: string, handler: EventHandler): this {
if (!this.handlers[event]) {
this.handlers[event] = [];
}
this.handlers[event].push(handler);
return this;
}
off(event: string, handler?: EventHandler): this {
if (!handler) {
delete this.handlers[event];
return this;
}
var handlers = this.handlers[event];
if (handlers) {
this.handlers[event] = handlers.filter(function (h) { return h !== handler; });
}
return this;
}
emit(event: string, ...args: any[]): void {
var handlers = this.handlers[event];
if (handlers) {
for (var i = 0; i < handlers.length; i++) {
try {
handlers[i].apply(null, args);
} catch (e) {
platform.log('EventEmitter error in "' + event + '": ' + (e as any)?.message);
}
}
}
}
}
game-frontend-sdk
运行在游戏前端浏览器环境中的 SDK,封装宿主协议、WebSocket 连接、重连与事件分发。
package.json
{
"name": "@game-platform/frontend-sdk",
"version": "0.1.0",
"description": "Game Frontend SDK — WebSocket client + event-driven lifecycle for game frontends",
"type": "module",
"main": "./dist/game-frontend-sdk.umd.js",
"module": "./dist/game-frontend-sdk.es.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/game-frontend-sdk.es.js",
"require": "./dist/game-frontend-sdk.umd.js"
}
},
"files": ["dist"],
"scripts": {
"build": "vite build && tsc --declaration --emitDeclarationOnly --outDir dist",
"dev": "vite build --watch"
},
"devDependencies": {
"typescript": "~5.7.0",
"vite": "^6.0.0"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationDir": "./dist",
"emitDeclarationOnly": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}
vite.config.ts
import { defineConfig } from 'vite'
import { resolve } from 'path'
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'GameFrontendSDK',
formats: ['es', 'umd'],
fileName: (format) => `game-frontend-sdk.${format}.js`,
},
rollupOptions: {
output: {
exports: 'named',
},
},
outDir: 'dist',
emptyOutDir: true,
},
})
src/index.ts
入口文件,导出全部公共 API。
// Game Frontend SDK — Public API
export { GameClient } from './client';
export { requestHostGameConfig, hostConfigMessageTypes, notifyGameExit } from './host';
export { GameLifecycle } from './lifecycle';
export { EventEmitter } from './events';
export type { GameClientOptions, GameMessage, GameState, HostGameConfig, MessageType } from './types';
src/types.ts
公共类型定义。
// Game Frontend SDK — Types
export type MessageType = 'event' | 'action' | 'system';
export interface GameMessage {
type: MessageType;
action: string;
payload: any;
timestamp: number;
seq: number;
playerId?: string;
gameId?: string;
sessionId?: string;
}
export type GameState = 'created' | 'waiting' | 'ready' | 'playing' | 'finished' | 'error' | 'timeout';
export interface GameClientOptions {
url: string;
gameId: string;
/**
* Obtains a fresh single-use WS ticket. The game iframe never holds the
* player's access token — the lobby host mints tickets on demand (one per
* connect/reconnect), so a compromised game bundle can only ever obtain
* one-time WS tickets, never the player's reusable API credential.
*/
ticketProvider: () => Promise<string | null>;
sessionId?: string;
/** Join as a read-only spectator (the game must allow spectating). */
spectate?: boolean;
/** Room code for room-mode games (alternative to sessionId). */
roomCode?: string;
autoReconnect?: boolean;
reconnectDelay?: number;
maxReconnectAttempts?: number;
}
export interface HostGameConfig {
wsUrl: string;
gameId: string;
/** Fetches a fresh single-use WS ticket from the lobby host (ticket-on-demand). */
requestTicket: () => Promise<string | null>;
sessionId?: string;
roomCode?: string;
spectate?: boolean;
}
src/host.ts
宿主协议:向大厅索取连接配置、一次性票据与退出通知。
import type { HostGameConfig } from './types';
const configRequestType = 'onlinegame:config:request';
const configResponseType = 'onlinegame:config:response';
const ticketRequestType = 'onlinegame:ticket:request';
const ticketResponseType = 'onlinegame:ticket:response';
const gameExitType = 'onlinegame:game:exit';
export interface HostConfigOptions {
timeoutMs?: number;
/**
* The expected parent origin. If omitted, it is inferred from document.referrer.
* When no specific origin can be determined the request FAILS CLOSED rather
* than broadcasting to '*' — a game must know its host origin so that a
* malicious embedder cannot receive the host's messages (or a WS ticket).
*/
targetOrigin?: string;
}
/**
* Requests the game configuration from the lobby host (the embedding parent).
* The response carries everything the game needs EXCEPT a credential: WS
* tickets are minted on demand via the returned `requestTicket` so the player's
* access token never enters the game iframe.
*/
export function requestHostGameConfig(options: HostConfigOptions = {}): Promise<HostGameConfig> {
const timeoutMs = options.timeoutMs ?? 5000;
const targetOrigin = options.targetOrigin ?? inferParentOrigin();
if (!targetOrigin) {
return Promise.reject(new Error('game host origin could not be determined; refusing to broadcast to "*"'));
}
return new Promise((resolve, reject) => {
if (window.parent === window) {
reject(new Error('game host is not available'));
return;
}
const timer = window.setTimeout(() => {
window.removeEventListener('message', onMessage);
reject(new Error('game host config request timed out'));
}, timeoutMs);
function onMessage(event: MessageEvent) {
if (event.source !== window.parent) return;
if (event.origin !== targetOrigin) return;
const data = event.data;
if (!data || data.type !== configResponseType) return;
const payload = data.payload as Partial<Omit<HostGameConfig, 'requestTicket'>> | undefined;
if (!payload?.wsUrl || !payload.gameId) {
return;
}
window.clearTimeout(timer);
window.removeEventListener('message', onMessage);
resolve({
wsUrl: payload.wsUrl,
gameId: String(payload.gameId),
requestTicket: () => requestHostTicket({ targetOrigin }),
sessionId: payload.sessionId ? String(payload.sessionId) : undefined,
roomCode: payload.roomCode ? String(payload.roomCode) : undefined,
spectate: Boolean(payload.spectate),
});
}
window.addEventListener('message', onMessage);
window.parent.postMessage({ type: configRequestType }, targetOrigin);
});
}
/**
* Requests a fresh single-use WS ticket from the lobby host. Called by the
* client on each connect/reconnect so a long game session survives the player's
* short-lived access token (the host re-mints).
*/
export function requestHostTicket(options: { targetOrigin: string; timeoutMs?: number } ): Promise<string | null> {
const targetOrigin = options.targetOrigin;
const timeoutMs = options.timeoutMs ?? 5000;
return new Promise((resolve, reject) => {
const timer = window.setTimeout(() => {
window.removeEventListener('message', onMessage);
reject(new Error('game host ticket request timed out'));
}, timeoutMs);
function onMessage(event: MessageEvent) {
if (event.source !== window.parent) return;
if (event.origin !== targetOrigin) return;
const data = event.data;
if (!data || data.type !== ticketResponseType) return;
window.clearTimeout(timer);
window.removeEventListener('message', onMessage);
resolve((data.payload && data.payload.ticket) || null);
}
window.addEventListener('message', onMessage);
window.parent.postMessage({ type: ticketRequestType }, targetOrigin);
});
}
export function hostConfigMessageTypes() {
return {
configRequest: configRequestType,
configResponse: configResponseType,
ticketRequest: ticketRequestType,
ticketResponse: ticketResponseType,
gameExit: gameExitType,
} as const;
}
/**
* Notifies the lobby host that the player invoked the game's OWN exit/back
* control, so the host can navigate back (route change, history back, ... —
* the host decides). Fire-and-forget with the same origin discipline as the
* config handshake: the message only goes to the inferred parent origin, and
* standalone previews (no embedding parent) return false instead of posting.
*/
export function notifyGameExit(options: { targetOrigin?: string } = {}): boolean {
const targetOrigin = options.targetOrigin ?? inferParentOrigin();
if (!targetOrigin || window.parent === window) return false;
window.parent.postMessage({ type: gameExitType }, targetOrigin);
return true;
}
function inferParentOrigin(): string | null {
if (!document.referrer) return null;
try {
return new URL(document.referrer).origin;
} catch {
return null;
}
}
src/client.ts
GameClient:WebSocket 连接、自动重连与消息收发。
// Game Frontend SDK — WebSocket Client
import { GameLifecycle } from './lifecycle';
import type { GameClientOptions, GameMessage } from './types';
let seqCounter = 0;
export class GameClient {
private ws: WebSocket | null = null;
private options: GameClientOptions;
private lifecycle: GameLifecycle | null = null;
private reconnectAttempts = 0;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private connected = false;
private intentionalClose = false;
constructor(options: GameClientOptions) {
this.options = {
autoReconnect: true,
reconnectDelay: 3000,
maxReconnectAttempts: 5,
...options,
};
}
/**
* Register lifecycle hooks.
*/
use(lifecycle: GameLifecycle): this {
this.lifecycle = lifecycle;
return this;
}
/**
* Connect to the game server. Asks the ticket provider for a single-use WS
* ticket (the lobby host mints it on demand), then opens the socket with
* ?ticket= so the player's access token never enters the iframe, the WS URL,
* proxy logs, browser history, or Referer.
*
* Returns a promise so callers can await the handshake kickoff; it does not
* wait for the socket to open. Each connect/reconnect requests a FRESH ticket,
* so a long session survives access-token expiry (the host re-mints).
*/
async connect(): Promise<void> {
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
return;
}
this.intentionalClose = false;
const ticket = await this.options.ticketProvider();
if (!ticket) {
if (!this.intentionalClose) {
this.tryReconnect();
}
return;
}
const params = new URLSearchParams({
ticket,
gameId: String(this.options.gameId),
});
if (this.options.sessionId) {
params.set('sessionId', this.options.sessionId);
}
if (this.options.roomCode) {
params.set('roomCode', this.options.roomCode);
}
if (this.options.spectate) {
params.set('spectate', '1');
}
const url = `${this.options.url}?${params.toString()}`;
this.ws = new WebSocket(url);
this.ws.onopen = () => {
this.connected = true;
this.reconnectAttempts = 0;
this.lifecycle?.onConnect?.();
};
this.ws.onmessage = (event) => {
try {
const msg: GameMessage = JSON.parse(event.data as string);
this.dispatch(msg);
} catch (e) {
console.error('[GameSDK] Failed to parse message:', e);
}
};
this.ws.onclose = () => {
this.connected = false;
this.lifecycle?.onDisconnect?.();
if (this.intentionalClose) return;
this.tryReconnect();
};
this.ws.onerror = (err) => {
console.error('[GameSDK] WebSocket error:', err);
};
}
/**
* Send a game action to the server.
*/
sendAction(action: string, payload: any): void {
// Spectators are read-only: never send actions (the server rejects them
// too, but blocking here avoids balanceless round-trips and error toasts).
if (this.options.spectate) {
console.warn('[GameSDK] spectators cannot send actions');
return;
}
this.send({
type: 'action',
action,
payload,
timestamp: Date.now(),
seq: ++seqCounter,
});
}
/**
* Send a system/lifecycle message to the server.
*/
private sendSystem(action: string, payload: any): void {
// Spectators cannot drive session lifecycle (ready/start/finish).
if (this.options.spectate) {
return;
}
this.send({
type: 'system',
action,
payload,
timestamp: Date.now(),
seq: ++seqCounter,
});
}
/**
* Notify the server that the game is ready.
*/
ready(): void {
this.sendSystem('session:ready', {});
}
/**
* Pull this player's authoritative state snapshot. The platform routes the
* request to the engine's onSync hook — valid in any live session state, so
* reconnects and first entry recover without retry dances. Spectator
* connections don't carry wallet state and are rejected server-side.
*/
sync(): void {
this.sendSystem('state:sync', {});
}
/**
* Notify the server to start the game.
*/
start(): void {
this.sendSystem('session:start', {});
}
/**
* Notify the server to finish the game.
*/
finish(): void {
this.sendSystem('session:finish', {});
}
/**
* Disconnect from the server.
*/
disconnect(): void {
this.intentionalClose = true;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
/**
* Check if connected.
*/
isConnected(): boolean {
return this.connected;
}
// --- Private ---
private send(msg: Omit<GameMessage, 'playerId' | 'gameId' | 'sessionId'>): void {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(msg));
}
}
private dispatch(msg: GameMessage): void {
const lc = this.lifecycle;
if (!lc) return;
switch (msg.type) {
case 'system':
this.dispatchSystem(msg, lc);
break;
case 'event':
lc.onEvent?.(msg.action, msg.payload);
break;
case 'action':
lc.onPlayerAction?.(msg.playerId ?? '', msg.action, msg.payload);
break;
}
}
private dispatchSystem(msg: GameMessage, lc: GameLifecycle): void {
switch (msg.action) {
case 'session:joined':
lc.onSessionJoin?.(msg.payload?.sessionId, msg.payload?.playerCount);
break;
case 'session:spectating':
lc.onSpectate?.(msg.payload?.sessionId, msg.payload?.playerCount);
break;
case 'session:state':
lc.onStateChange?.(msg.payload?.state);
if (msg.payload?.state === 'ready') {
lc.onReady?.();
}
break;
case 'session:started':
lc.onStart?.(msg.payload);
break;
case 'session:finished':
case 'game:finished':
lc.onFinish?.(msg.payload);
break;
case 'error':
lc.onError?.(msg.payload?.message ?? 'Unknown error');
break;
default:
// Platform-level system messages (e.g. wallet:error with the exact
// wallet failure) must reach the game — without this fallback they were
// silently dropped and players saw a generic result instead of the real
// cause (e.g. insufficient balance after an admin adjustment).
lc.onEvent?.(msg.action, msg.payload);
break;
}
}
private tryReconnect(): void {
if (!this.options.autoReconnect) return;
if (this.reconnectAttempts >= (this.options.maxReconnectAttempts ?? 5)) {
console.warn('[GameSDK] Max reconnect attempts reached');
this.lifecycle?.onReconnectFailed?.();
return;
}
this.reconnectAttempts++;
const delay = this.options.reconnectDelay ?? 3000;
console.log(`[GameSDK] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
this.reconnectTimer = setTimeout(() => {
this.connect();
}, delay);
}
}
src/lifecycle.ts
GameLifecycle 基类,游戏视图继承并重写需要的回调。
// Game Frontend SDK — Lifecycle hooks
import type { GameMessage, GameState } from './types';
/**
* Abstract lifecycle hooks that game developers override.
*/
export abstract class GameLifecycle {
/**
* Called when the WebSocket connection is established.
*/
onConnect?(): void | Promise<void>;
/**
* Called when the session is joined successfully.
*/
onSessionJoin?(sessionId: string, playerCount: number): void | Promise<void>;
/**
* Called when this connection is confirmed as a spectator. Spectators receive
* events/state but never drive lifecycle or send actions.
*/
onSpectate?(sessionId: string, playerCount: number): void | Promise<void>;
/**
* Called when the game is ready to start.
*/
onReady?(): void | Promise<void>;
/**
* Called when the game starts (received session:started from the platform —
* the session entered Playing and gameplay actions are now accepted).
*/
onStart?(data: any): void | Promise<void>;
/**
* Called when a game event is received from the engine.
*/
onEvent?(action: string, payload: any): void | Promise<void>;
/**
* Called when another player performs an action.
*/
onPlayerAction?(playerId: string, action: string, payload: any): void | Promise<void>;
/**
* Called when the game finishes.
*/
onFinish?(data: any): void | Promise<void>;
/**
* Called when an error occurs.
*/
onError?(error: string): void | Promise<void>;
/**
* Called when the connection is closed.
*/
onDisconnect?(): void | Promise<void>;
/**
* Called when reconnection attempts are exhausted and the connection is
* permanently lost. Use to surface a terminal "disconnected" state to the
* user (distinct from a transient drop, which just fires onDisconnect).
*/
onReconnectFailed?(): void | Promise<void>;
/**
* Called on state transitions.
*/
onStateChange?(state: GameState): void | Promise<void>;
}
src/events.ts
事件分发器。
// Game Frontend SDK — Event System
export type EventHandler = (...args: any[]) => void;
export class EventEmitter {
private handlers: Map<string, EventHandler[]> = new Map();
on(event: string, handler: EventHandler): this {
if (!this.handlers.has(event)) {
this.handlers.set(event, []);
}
this.handlers.get(event)!.push(handler);
return this;
}
off(event: string, handler?: EventHandler): this {
if (!handler) {
this.handlers.delete(event);
return this;
}
const handlers = this.handlers.get(event);
if (handlers) {
this.handlers.set(event, handlers.filter(h => h !== handler));
}
return this;
}
emit(event: string, ...args: any[]): void {
const handlers = this.handlers.get(event);
if (handlers) {
for (const handler of handlers) {
try {
handler(...args);
} catch (e) {
console.error(`[GameSDK] Error in event handler "${event}":`, e);
}
}
}
}
removeAllListeners(): void {
this.handlers.clear();
}
}