Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

平台简介

在线游戏平台是一个面向网页游戏的联机游戏平台。玩家在浏览器中即可完成游戏发现、开局与对战,无需下载客户端。游戏开发商按平台规范开发一次,即可获得游戏分发、联机对战、匹配、结算与运营数据能力。

平台能力

  • 游戏即点即玩:游戏以 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 APIWebSocket 网关与游戏逻辑运行时,负责会话状态机、匹配、回合结算与公平随机
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

设计原则:

  1. 凭证不进游戏前端:游戏前端通过宿主协议向大厅索取一次性 WebSocket 票据,票据单次有效、30 秒过期,玩家长期凭证不会离开大厅。
  2. 权威在服务端:影响玩法与资金的操作均由平台托管的游戏逻辑裁决,前端只负责表现。
  3. 资金平台托管:游戏逻辑只能通过平台回合接口发起扣款与奖励,结算原子完成并全程留痕。

对局链路

  • 会话状态机为 created → waiting → ready → playing → finished,error 与 timeout 为终态,玩法动作仅在 playing 状态受理。
  • quick 模式由匹配器按游戏自动池化玩家,人数达到 minPlayer 即自动开局。
  • room 模式通过 REST 创建房间并获得 6 位房间码,其他玩家凭码加入等待室,房主满足人数后开局。
  • 观战为会话级只读连接,需游戏声明允许。

资金链路

玩家钱包由余额与流水两部分组成,一切资金变动包裹在显式回合中,整回合原子提交、失败整体回滚。余额变更在事务提交后经 Redis 发布订阅广播,由网关实时推送到玩家的全部连接,游戏内余额显示无需轮询。充值遵循实名认证、下单、渠道回调、同事务入账的闭环,幂等键防止重复入账。

注册与登录

注册账号

进入大厅 game.zihua-chen.cn,点击右上角注册,填写以下信息:

字段要求
用户名必填,3–50 个字符,全局唯一
昵称选填,最长 20 字符,游戏内展示名,留空时使用用户名
邮箱必填,用于找回密码
密码至少 6 位

注册即表示已知悉平台使用条款:本平台仅用于学习研究与技术演示,不涉及真实资金交易。注册成功后自动登录,并返回之前访问的页面。

登录

支持用户名加密码登录。未登录状态下点击游戏卡片、充值等入口,会先进入登录页,登录后自动返回。

找回密码

  1. 在登录页输入注册邮箱,获取 6 位重置码。重置码 10 分钟内有效,沙箱环境下由平台日志下发,可联系平台获取。
  2. 输入重置码与新密码完成重置。重置后所有设备需重新登录。

个人中心不提供改密码入口,修改密码统一通过找回密码流程完成。

注销账号

个人中心的账户与安全页提供注销入口,操作需输入登录密码确认。注销后账号不可恢复,交易流水按合规要求保留用于对账,注销成功后自动退出登录。

游戏大厅

大厅首页 game.zihua-chen.cn 是玩家发现游戏的主入口,页面自上而下分为以下区块:

区块说明
公告条展示平台公告,内容由运营配置,仅显示标题
轮播位运营配置的推荐内容,自动轮换,悬停暂停,支持滑动与箭头切换,绑定游戏的轮播可一键进入
最近在玩登录后展示近期游玩过的游戏,横向滑动,点击继续
我的收藏登录后展示收藏的游戏
编辑推荐运营配置的推荐位,点击进入对应游戏
分类筛选全部与各分类标签,点击过滤下方游戏列表
游戏网格全部上架游戏,加载中显示骨架屏,失败可重试

游戏卡片展示封面、分类角标与标题,悬停出现立即进入按钮,整卡可点击。未登录点击游戏会先进入登录页,登录后自动回到该游戏。

搜索

点击顶栏搜索入口或使用快捷键 Ctrl/⌘+K 打开全屏搜索。

  • 输入即搜,最多返回 20 条结果,回车直达第一个结果。
  • 无关键词时展示最近 8 条搜索历史与热门游戏入口,历史可一键清除。
  • Esc 键先清空关键词,再次按下关闭搜索。

收藏与最近游玩

进入游戏页后可收藏或取消收藏。有游玩记录的游戏出现在首页最近在玩,点击封面直接回到游戏。

开始游戏

进入游戏页后,游戏以 iframe 方式嵌入运行。开局方式由游戏配置决定,分为 quick 与 room 两种。

quick 模式

点击游戏卡片直接进入,无需手动操作。平台自动将玩家加入该游戏的匹配队列,人数达到开局要求后自动开始对局。该模式适合对战与牌桌类玩法。

room 模式

进入游戏页先显示房间面板。

  • 创建房间:房主获得 6 位房间码,可复制邀请链接发送给好友。此前有未开始的房间时会自动恢复。
  • 加入房间:输入好友的房间码加入,房间码自动转为大写。

等待室展示房间号、状态、玩家数与玩家列表,房主带房主标记。人数满足开局要求后,房主可点击开始游戏,其余玩家等待即可,也可离开房间退出等待。对局开始后等待室收起,游戏画面接管。

对局中

  • 余额显示在顶栏并实时更新。游戏内的余额变动由平台实时推送,无需刷新页面。
  • 断线或刷新页面后重新进入游戏,平台会带回原对局并恢复局面。离席玩家的座位通常由游戏托管,归队后自动恢复操作。
  • 支持观战的游戏可通过观战链接只读观看对局。
  • 点击游戏内返回按钮回到大厅首页,直接关闭页面不影响对局状态。

充值

大厅顶栏点击充值进入充值页。充值前需完成实名认证,未实名时页面会先引导认证。

实名认证

填写真实姓名与身份证号提交。前端按国家标准校验证件号格式,通过后显示认证状态与证件尾号。身份信息仅用于合规校验,平台不以明文保存。

充值与到账

平台提供多档充值面额,兑换比例为 1 元兑换 100 金币,到账实时推送。

  1. 选择档位并确认,生成订单,页面展示订单号与状态。
  2. 完成支付。当前支付渠道为沙箱模拟支付,不发生真实扣款。
  3. 支付成功后金币自动到账,顶栏余额即时刷新。

订单状态包括待支付、支付中、已到账、已失败、已过期与已退款。未完成支付的订单不会扣款,支付结果异常时可稍后在充值记录中查看。

充值记录

充值页下方展示最近 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

设计原则:

  1. 凭证不进游戏前端:游戏前端通过宿主协议向大厅索取一次性 WebSocket 票据,票据单次有效、30 秒过期。
  2. 权威在服务端:影响玩法与资金的操作均由游戏逻辑裁决,前端只负责表现。
  3. 资金平台托管:游戏逻辑只能通过平台回合接口发起扣款与奖励,结算原子完成并全程留痕。

接入流程

注册开发商账号 → 平台资质审核 → 登录开发商后台
  → 创建游戏,录入基础信息与联机参数
  → 开发游戏前端与游戏逻辑 → 本地联调自测
  → 提审,上传前端包并提交游戏逻辑包
  → 平台审核 ──驳回──▶ 按意见修改后重新提审
       └──通过──▶ 自动发布上架 → 运营数据跟踪 → 版本迭代
阶段责任方说明
入驻审核平台开发商注册后为待激活状态,资质审核通过后开通登录
创建游戏开发商后台录入游戏基础信息与联机参数
开发联调开发商使用双 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 位
email联系邮箱
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 字符,大厅卡片图
sessionModequick 为自动匹配,room 为房间邀请,缺省 quick
minPlayer1 到 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:startedsessionId, playerCount进入对局并开启操作,已映射到 onStart
session:finishedsessionId终局,已映射到 onFinish
session:member_leftsessionId, playerId有玩家离席,建议展示等待归队或托管中
session:timeoutsessionId会话超时结束,展示对应提示
errormessage平台或游戏逻辑错误,展示并允许重试 sync
wallet:balancebalance, version, source余额实时推送,必须按 version 单调守卫,只应用比本地新的版本,防止乱序回退
wallet:errorrequestId, roundId, error结算被拒,展示具体原因
fair:commitmentseedId, commitment公平随机承诺,存档供终局验证
fair:revealseedId, 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 版本化推送,配合结算结果中的版本号使用:

  1. 收到 wallet:balance 时,仅当 version 大于本地水位才更新显示并抬高水位。
  2. 收到结算结果时,应用结果携带的 balance_version,将水位至少抬到该值,丢弃此前积压的旧推送。
  3. 页面恢复可见或重连后,调用 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与前端包对应
backendJsBundlebundle 全文,首次提审必填,更新时留空表示沿用上一版本

增量提交规则:

  • 首次提审必须同时包含前端包与游戏逻辑包。
  • 后续版本可只提交变更的一侧,未提交侧自动继承上一已提交版本,并在包记录中标注来源版本,便于审核聚焦变更内容。
  • 两侧均未变更的提交会被拒绝,错误码 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 单调守卫,无轮询
  • 事件带游戏命名空间前缀,无平台保留名冲突
  • 本地联调环境全流程自测通过,覆盖匹配、开局、结算、重连与观战

常见驳回原因

  1. 游戏逻辑依赖运行环境不提供的能力,如 fetch、DOM、eval。
  2. 资金操作未闭合回合,或以 getBalance 预检代替结算失败处理。
  3. 随机类玩法未使用公平随机,或时序错误,先取随机数后承诺。
  4. 前端缺少 index.html,或使用 history 路由导致刷新 404。
  5. 断线重连后无法恢复对局,未实现 onSync。
  6. 算法在复杂局面下超过 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();
  }
}