VibeHub

开发文档

给游戏接入 VibeHub 的账号 / 云存档 / 联机。引导 Prompt复制给 AI 即可一键集成;AI 可直接读取 llms.txt

VibeHub 游戏后端 SDK

🤖 给 AI 用:开发前先读 https://vibe.lumigrav.space/llms.txt(索引)与 /sdk/v3/llms-full.txt(高性能联机规范、红线)。给用户的可复制引导词见 docs/prompts.md

给你的网页游戏接上 账号登录、三层数据、多人联机,无需自己搭后端。我们只提供通用基础设施——排行榜、背包、房间锁、段位匹配等具体功能,全由你用 SDK 在自己的代码里组合实现。

游戏托管在 vibeapps.lumigrav.space,账号与数据在 VibeHub 主站。SDK 帮你跨源打通,玩家用 VibeHub 账号一键登录。

引入 SDK(一行,必须用绝对地址——游戏页面在 vibeapps 域,相对路径 /sdk/... 会 404):

<script src="https://vibe.lumigrav.space/sdk/v3/vibehub.js"></script>

运行时可通过 VibeHub.version 读取完整内部版本;VibeHub.channel 可识别当前加载的是 Stable、Beta 还是未知来源。TypeScript 声明文件位于 https://vibe.lumigrav.space/sdk/v3/vibehub.d.ts


SDK 版本与兼容策略

推荐作品使用稳定大版本地址 /sdk/v3/vibehub.js

  • v3 的 Bug 修复和向后兼容的新能力会自动生效,作品无需重新上传。
  • 已打开的页面不会热更新,需要重新加载后才会取得新版 SDK。
  • /sdk/beta/vibehub.js 是可能随时变化的测试通道,只有明确接受风险的诊断或测试作品 才应使用;普通作品不要加载 Beta。
  • 无版本路径 /sdk/vibehub.js/sdk/vibehub.d.ts/sdk/llms-full.txt 不再提供; 每个作品必须在 URL 中明确选择 Major,查询参数不能代替版本路径。
  • 只有不兼容升级才使用新的 Major 路径,例如 /sdk/v4/vibehub.js;v3 作品不会被 静默切换到 v4。

30 秒上手

const vibe = await VibeHub.init({ work: '你的作品slug' });

// 1. 登录
const user = await vibe.login();              // {id, name, image},弹窗授权,token 仅驻留当前页面内存

// 2. 三层数据
await vibe.save.set('progress', { level: 5 });          // 玩家自己
await vibe.save.get(["skin","settings"]);               // 批量读取
await vibe.global.get('config');                        // 全游戏(读)
const room = await vibe.room.join('room-1');            // 进房
await room.data.set('score', { a: 3, b: 1 });           // 单局共享(仅房主可写)

// 3. 联机(P2P)
room.onMessage((msg, fromId) => { /* 收消息 */ });
room.onPeer(({ type, id }) => { /* 'join' | 'leave' */ });
room.send({ x: 10 });                                   // 广播;room.send(obj, id) 发给指定人

账号登录与退出

SDK 只提供认证能力,不强行注入游戏 UI。游戏可以按自己的视觉风格放置登录、退出和账号状态;使用下面这些状态与 API 即可:

API说明
await vibe.login()打开 VibeHub 登录/授权弹窗;成功返回 { id, name, image },名称和头像与主站公开个人资料一致
vibe.logout()同步退出当前作品的游戏账号,清除内存 token、当前用户、player 缓存和旧 SDK 遗留 token
vibe.isLoggedIn()当前 SDK 实例是否已经完成登录
vibe.onAuthChange(callback)登录或退出时收到 user / null;返回取消监听函数
vibe.user当前内存中的用户;尚未登录时为 null

vibe.logout() 不会退出 VibeHub 主站账号,也不会删除云存档。因此玩家再次登录时,如果主站会话仍有效,授权弹窗会继续使用该主站账号。它也不会自动退出当前联机房间;游戏应在收到 null 时停止需要登录的操作,并按玩法决定是否 room.leave()

作品 token 只保存在当前 SDK 实例内,不写入 localStoragesessionStorage。刷新作品页面后 需要再次授权;如果主站仍已登录,这通常只是一次很快的授权跳转。弹窗不可用时,授权页可把 token 通过 URL fragment 带回原作品,VibeHub.init() 会自动接收并立即清除 fragment。

SDK 不内置固定样式的账号组件,只提供上面的状态和方法。浏览器会直接下载 vibehub.js,因此开发者能查看带注释的源文件;这些注释是实现补充,不替代本文档和 vibehub.d.ts 所定义的公开 API。

无样式登录/退出 UI

下面是可以直接复用的最小框架。它只负责状态和事件,不包含 CSS:

<div data-vibe-auth>
  <span data-vibe-auth-status aria-live="polite">未登录</span>
  <button type="button" data-vibe-auth-login>登录</button>
  <button type="button" data-vibe-auth-logout hidden>退出游戏账号</button>
</div>

<script type="module">
  function mountVibeAuth(root, vibe) {
    if (!root) throw new Error("找不到登录 UI 根元素");
    const status = root.querySelector("[data-vibe-auth-status]");
    const loginButton = root.querySelector("[data-vibe-auth-login]");
    const logoutButton = root.querySelector("[data-vibe-auth-logout]");
    if (!status || !loginButton || !logoutButton) {
      throw new Error("登录 UI 缺少必要的 data-vibe-auth-* 元素");
    }

    function render(user) {
      status.textContent = user ? `已登录:${user.name || "VibeHub 用户"}` : "未登录";
      loginButton.hidden = Boolean(user);
      logoutButton.hidden = !user;
      loginButton.disabled = false;
      logoutButton.disabled = false;
    }

    async function login() {
      loginButton.disabled = true;
      status.textContent = "登录中…";
      try {
        await vibe.login();
      } catch (error) {
        status.textContent = error instanceof Error ? error.message : "登录失败";
        loginButton.disabled = false;
      }
    }

    function logout() {
      logoutButton.disabled = true;
      vibe.logout();
    }

    loginButton.addEventListener("click", login);
    logoutButton.addEventListener("click", logout);
    const stopWatching = vibe.onAuthChange(render);
    render(vibe.user);

    // 页面或组件销毁时调用,避免重复绑定事件。
    return function unmountVibeAuth() {
      stopWatching();
      loginButton.removeEventListener("click", login);
      logoutButton.removeEventListener("click", logout);
    };
  }

  const vibe = await window.VibeHub.init({ work: "你的作品slug" });
  const unmountAuth = mountVibeAuth(document.querySelector("[data-vibe-auth]"), vibe);
  // 不再使用这段 UI 时:unmountAuth();
</script>

原生 <button> 已支持键盘操作,aria-live="polite" 会播报登录状态变化。开发者只需用 CSS 选择 [data-vibe-auth][data-vibe-auth-login] 等属性添加样式。


核心概念:三层数据作用域

一张通用 KV 表,三个作用域。这是整套架构的基础。

SDK存什么写权限读权限生命周期
玩家vibe.save.*存档、皮肤、英雄、武器、设置、个人战绩玩家本人玩家本人长期
单局room.data.*这一局的共享状态、房间元数据房间 owner(房主/创建者)同作品且知道 roomId 的登录玩家短期(默认 24h TTL,可自定义)
全局vibe.global.*排行榜、配置表、全服统计、公告作品创作者所有玩家长期

每个作用域的方法一致:get(key) / set(key, value) / all() / remove(key),可加 namespace 分组。

批量读取:vibe.save.get(["k1", "k2"]) 一次 HTTP 取多 key,返回 {k1: v1, k2: v2}。 客户端缓存:get() 走内置 LRU 缓存(256 条),重复读取同 key 不产生网络请求。set()/remove() 即时同步缓存。

⚠️ 版本更新注意:数据绑定"作品(slug)",跟代码版本无关——同一 slug 下更新游戏,数据保留。但你改了数据结构要自己做兼容(如 if (p && !p.v2) p = {...升级}),SDK 不自动迁移。换 slug 就是另一份数据。

对局实时状态不要写库

联机中高频变化的状态(比分、位置、出牌、画图)走 P2P 在玩家间同步,不写数据库。 数据库只存"需要跨玩家持久"的东西(排行、背包、房间元数据)。这样延迟低、服务器无压力。

玩家层能干什么

除了存档,vibe.save 适合做设置/偏好(音量、键位)、进度解锁(关卡、成就)、个人最高分/纪录、每日签到、背包养成、教程状态、联机战绩等——本质是"按玩家隔离、跨设备的 KV",比 localStorage 强在换设备不丢。


联机:两种拓扑 + VibeNet 中继

vibe.room.join(roomId, { topology: 'host' | 'mesh' }),默认 host

拓扑对比

host(房主权威,默认)mesh(全互联)
连接每人只连房主两两全连
实时状态房主演算并广播权威状态各自广播、各自演算
权威房主(状态一致、防作弊)无(人人平等)
适合棋牌、你画我猜、答题、有裁判双人对打、协作绘图
规模连接线性,易扩连接平方增长

VibeNet 公共浏览器中继(自动降级,默认开启)

SDK 内置能力,游戏无需写任何中继代码。 以下行为全部由 SDK 自动完成。

游戏得到的效果

  1. P2P 直连成功 → 最佳延迟,始终优先走直连
  2. 某条直连失败 → 自动切换到全局节点池中的主 relay
  3. 主 relay 失败 → 已连接暖备立即提升;P2P 恢复后短暂双发再切回直连
  4. 断线 → 自动重连(指数退避 1s→60s,最长等待 120s)

内部实现(SDK 自动处理,仅供参考)

  • 主站和官方 SDK 页面在可运行时匿名贡献同一个全局节点池;登录、退出和 Room 生命周期不启停贡献
  • 节点发现按剩余连接、总上行、总缓冲和软网络多样性做无放回随机轮换
  • 稳态只有一个主路径发送业务,最多一个暖备仅保活与探测;切换窗口最多双路短暂发送
  • Wire v2 携带 pathEpoch + streamId + uint32 sequence;接收端仅在 AES-GCM 验证成功后 提交有界去重窗口,因此损坏副本不会误杀后续有效副本
  • host 拓扑由房主转发非房主之间的广播或定向消息

安全说明

  • Room 业务 payload 使用房间级 AES-GCM 加密与完整性校验;公共 relay 只能转发密文, 修改来源、目标、序号或 payload 都会被接收端拒绝。房内参与者共享该房间密钥,因此这不是 针对恶意房间成员的端到端隔离
  • 匿名 relay 的短期凭证只用于防止伪造节点信令,不代表用户身份;它不能调用游戏数据 API
  • 每条连接还必须持有绑定 relay + work + room + player 的短期 grant;公共节点只能在该 连接所属房间内转发 SDK wire,不能访问 URL、TCP 服务或成为 VPN/任意网络代理
  • 公共 relay 仍能看到连接双方的 IP/ICE 候选、流量大小与时序;“匿名节点”不等于网络匿名

新 API

// 玩家列表(relay 包含 primary / warm / candidate 角色)
const players = room.peers();
// [{id, open, latency, relay, role?, score?, reconnecting}]

// 当前自动传输状态,用于诊断 UI
const network = room.networkStats();
// {state, pathEpoch, primaryRelayId, warmRelayId, switching,
//  lastSwitchReason, duplicateSuppressed}

// 手动重连
room.reconnect(peerId);

// onPeer 新增事件类型
room.onPeer(({type, id, active, reason, detail}) => {
  // type: 'join' | 'leave' | 'connecting' | 'reconnecting' | 'relay' | 'error'
});

三人场景中,如果 A、B 都在 NAT 后,而 C 公网可达且网络良好,只要 A↔C、B↔C 都能建立 WebRTC,C 就能桥接两人,通常可以联机。但这不是 TURN 等价物:对称 NAT、 企业防火墙或 UDP/ICE 被禁时,A 或 B 仍可能连不上 C。需要覆盖这类极端双人网络时, 后续仍应部署 TURN 或平台自有 relay 服务。

批量读取 & 缓存

// 批量取多个 key(一次 HTTP)
const { skin, settings } = await vibe.save.get(["skin", "settings"]);

// 单次读取走客户端缓存(LRU 256 条),重复读无网络开销
const progress = await vibe.save.get("progress");

Room 数据 TTL

// room scope 默认 24h 过期,可自定义
await room.data.set("matchResult", { winner: 1, ts: Date.now() });
// TTL 2 小时
await room.data.set("tempConfig", { v: 2 }, { ttl: 7200 });
// 永不过期
await room.data.set("persistent", { v: 3 }, { ttl: 0 });

二进制消息

room.send(arrayBuffer);          // 直接发 ArrayBuffer
// onMessage 收到的 payload 保持原始类型

Token 生命周期

  • Token 2 小时有效,且只驻留当前页面内存。
  • 遇到 401 时 SDK 会清除当前登录态、触发 onAuthChange(null) 并抛出 error.code === "AUTH_EXPIRED";SDK 不会在后台请求中突然弹窗。游戏应回到登录 UI, 由玩家点击后再次调用 login(),需要时重新加入房间。

轻量状态同步与快照插值

// 小体量、最后写入胜出的共享状态;更新会自动广播。
room.state.on("phase", (value, previous) => renderPhase(value));
room.state.set("phase", "playing");

// 高频权威快照仍由游戏自己 send;sync 只负责本地缓冲与插值。
room.sync.push("world", { _t: Date.now(), x: 10, y: 20 });
const interpolated = room.sync.get("world");
room.sync.clear("world");

room.state 适合准备状态、回合阶段、低频开关,不适合每帧物理状态。room.sync 不产生网络请求,也不会自动发送快照;游戏收到 room.send() 的快照后再调用 push()


匹配:房间发现

房间发现只读 room 层的元数据 + presence(这是 scope=room,不是全局层)。

// join 会原子认领一个尚无 owner 的隐藏房间;无需先 announce。
const room = await vibe.room.join("room-1");

// owner(房主/创建者)需要大厅可发现时再登记
await room.announce({ open: true, listed: true, max: 6, mode: '经典', /* 任意自定义字段 */ });

// 任何玩家列出开放的局
const rooms = await vibe.rooms.list();   // [{roomId, players, open, max, mode, ...}]

// 快速匹配:找第一个开放且未满的房间,返回 roomId;没有则 null(你再自建)
const roomId = await vibe.rooms.quickJoin();
const room = await vibe.room.join(roomId ?? 'room-' + Date.now());

presence 心跳由 SDK 自动维护(进房即开始),人数实时反映在 players 字段。


用积木搭高级功能(示例)

我们只提供上面的基础接口,下面这些由你在自己的代码里实现:

房间锁 / 密码房

// 房主:announce 时存一个密码哈希(或明文,MVP)
await room.announce({ listed: true, pass: '1234' });
// 加入者:先读元数据校验,再 join
const meta = await vibe.rooms.get(roomId);
if (meta.pass !== prompt('密码?')) return alert('密码错误');
await vibe.room.join(roomId);

私密房(不进大厅)announce({ listed: false }),只把 roomId 发给朋友。

段位匹配quickJoin({ filter: r => Math.abs(r.mmr - myMmr) < 200 })

全服排行榜:房主结算后写 room.data → 创作者定期汇总写 vibe.global.set('leaderboard', top),大家 vibe.global.get('leaderboard') 读。

踢人:host 拓扑里房主关闭与该玩家的 DataChannel 并广播。


一键复制给 AI 的集成指令

我的网页游戏托管在 VibeHub。请用 VibeHub SDK 实现登录/退出、三层数据和多人联机。

SDK 通过 <script src="https://vibe.lumigrav.space/sdk/v3/vibehub.js"></script> 引入,
全局对象 window.VibeHub。网络操作使用 async;logout/isLoggedIn/onAuthChange 是同步 API。

1) 初始化并登录:
   const vibe = await VibeHub.init({ work: '我的作品slug' });
   const user = await vibe.login(); // {id,name,image},弹窗授权;token 仅驻留当前页面内存
   // 用户主动退出时调用 vibe.logout();它不会退出 VibeHub 主站
   用 vibe.onAuthChange(user => {}) 更新账号 UI,至少提供登录和退出按钮。

2) 三层数据(方法一致:get/set/all/remove,可加 namespace):
   vibe.save.*    玩家自己的数据(存档/皮肤/设置),本人读写
   room.data.*    单局共享数据,仅房主可写、本局可读
   vibe.global.*  全游戏数据(排行/配置),创作者写、所有人读
   读取后要做版本兼容(如 if(p&&!p.v2) p={...}),SDK 不自动迁移。
   批量读取:vibe.save.get(["skin","settings","progress"]) 一次取多个 key。
   客户端缓存:get() 自动 LRU 缓存(256 条),重复读不产生网络请求。
   Room 数据 TTL:room scope 写入默认 24h 过期,房间关闭后自动失效。

3) 联机(WebRTC P2P + VibeNet 全局浏览器中继,自动开启):
   const room = await vibe.room.join(roomId, { topology: 'host' });
   room.onMessage((msg, fromId) => {});
   room.onPeer(({type,id,reason,detail}) => {});  // join|leave|connecting|reconnecting|relay|error
   room.send(obj); room.send(obj, id); room.isHost; room.peers(); room.networkStats(); room.reconnect(id);
   room.leave();
   对局实时状态走 P2P 不写库;host 拓扑下玩家 send 输入、房主广播权威状态。

4) 匹配(房间发现,读 room 层元数据):
   await room.announce({ open:true, listed:true, max:6, ...自定义 });  // 房主登记
   const rooms = await vibe.rooms.list();        // 列出开放的局
   const id = await vibe.rooms.quickJoin();       // 找未满的局,没有返回 null
   房间号、密码锁、私密房、段位筛选都由我用这些自行实现。

请把登录、数据、联机接入我的游戏,UI 用简洁的深色风格。

限制与说明

  • 单条 value ≤ 16KB(GAME_KV_MAX_BYTES)。
  • 单条 P2P 消息编码后 ≤ 65535 bytes;大文件应使用对象存储,不要塞入 DataChannel 消息。
  • 联机默认 P2P(STUN 打洞)+ 全局匿名浏览器中继兜底。它能明显增加可用路径,但不保证穿透对称 NAT/严格防火墙,也不替代 TURN。
  • Wire v2 最多转发两跳,按来源 + 路径代际 + 流 + 32 位序号在认证后去重;网络恢复时会优先回到直连。
  • relay 评分在用户浏览器本地维护——同一节点对不同玩家可能有不同分数(取决网络状况)。
  • 主站与官方 SDK 的匿名节点默认开启;注册只获得绑定临时 peerId 的短期能力凭证。
  • presence 心跳约 20s 一次,45s 无心跳视为离线。
  • host 拓扑中房主掉线影响全房;SDK 目前不会迁移权威状态,游戏应结束本局或自行重建新房间。
  • Host 认领:第一个原子 claim 成功的玩家成为稳定 owner/host;房间元数据过期后才能由新玩家接管。它不再依赖随机 peerId 比较。
  • 直连与 relay 都走统一二进制 wire 协议;业务 payload 使用房间级 AES-GCM, 保证完整性并阻止公共 relay 读取;JSON、string、ArrayBuffer 和 Uint8Array 保持原类型。
  • Room 数据 TTL:room scope 写入默认 24h 过期;可传 { ttl: 秒数 } 自定义,传 0 永久。过期数据自动失效(读取返回 null)。
  • 批量读取vibe.save.get(["k1", "k2"]) 一次取多 key,减少 HTTP 往返。
  • 客户端缓存get() 走按作品/用户/房间隔离的 LRU 缓存(256 条);set()/remove() 只在服务端成功后更新,登出清理当前用户的 player 缓存。
  • 中继延迟约 20-100ms(比直连多一跳),对 FPS 等延迟敏感游戏建议客户端做插值缓冲。

认证与鉴权

  • 换 token/api/game-auth 要求主站登录会话,且作品 slug 必须真实存在——只有部署在 VibeHub 上的游戏才能签发 token。
  • 调接口/api/sdk/* 校验 HMAC 签名的 game token(绑定用户 + 作品,2 小时过期)。
  • 写权限:player 层仅本人;room 层仅 owner;global 层仅作品创作者。读权限见上表。
  • Room 读取边界:MVP 把随机/私密 roomId 当作 capability;服务端校验作品和 roomId, 但没有持久化成员名单。敏感数据仍不应写入 room.data
  • 跨源:仅 PROJECTS_ORIGIN 可跨域调用。

MVP 边界:服务端校验 slug 存在,但未强制校验请求确实来自该 slug 页面。涉及排行榜/竞技奖励时会再加 origin↔slug 绑定校验。

API 参考(底层,一般用不到)

端点方法说明
/connect页面授权页
/api/game-authPOST主站会话换 game token
/api/sdk/meGET当前用户
/api/sdk/data?scope=&room=&ns=&key=GET/PUT/DELETE三层 KV(scope=player/room/global)
/api/sdk/roomsGET/POST房间列表 / get·claim·announce·presence·close
/api/sdk/signalPOST/GETP2P 信令
/api/agent/deploy-status?project=GETAgent 查部署(Bearer API Token)
/api/tagsGET所有标签与分类(公开,无需认证)
/api/projects?q=&page=GETAgent 查找当前账号项目;更新时必须精确匹配 slug
/api/upload/presignPOST获取新增/变化文件的 OSS POST 表单或本地 PUT 地址
/api/projects/:idPOST部署(deploy),body 可带 work/version 见下

deploy 时自动填表(POST /api/projects/:id

files 外可携带,让 Agent 一次请求填好资料、无需再进后台:

  • work(可选):{ title, description, categoryId, tagIds, coverImage, screenshots }。创建时全量写入;更新时只改出现的字段。coverImage/screenshots作品文件相对路径(须在本次 files 里),服务端拼完整 URL。玩法说明写进 description(如 ## 玩法 分段)。标签只能选 /api/tags 返回的现有标签。
  • version(可选):{ note }。传了记一条更新日志,版本号平台自动递增(1,2,3...),只需写 note
  • files 中应携带预签名阶段使用的 SHA-256 hash;部署成功后平台持久化 hash,下一次 更新只返回新增或变化文件。OSS multipart 文件段的 MIME 必须与 uploadFields["Content-Type"] 完全一致,文件字段最后添加。

Agent 复制指令见 skills/vibehub-publish/SKILL.md(或 GET /api/skill)。

官方 CLI:首次发布与更新

# 首次发布会创建项目
node cli/vibehub.js deploy --token <API_TOKEN> --name "作品名" --dir <部署根目录>

# 更新按当前账号下的 slug 精确查找,不会创建重复项目
node cli/vibehub.js update --token <API_TOKEN> --slug <项目slug> --dir <部署根目录> \
  --note "本次更新说明"

两条命令都要求部署根目录顶层存在 index.html,并自动计算 SHA-256。更新会复用 presign 返回的 skipped、上传变化文件,并在 deploy 阶段提交完整文件清单;--name--description--note 均为更新时的可选参数。

所有 /api/sdk/*Authorization: Bearer <gameToken>,仅放行 PROJECTS_ORIGIN 跨源。


Agent 闭环调试:查询部署状态

开发游戏的 Agent 可用 API Token 轮询部署进度,自动判断"部署成功拿到 URL"或"失败拿到报错",从而闭环改代码重新部署。

curl -H "Authorization: Bearer <API_Token>" \
  "https://vibe.lumigrav.space/api/agent/deploy-status?project=<slug>"

返回 { slug, projectStatus, deployed, url, progress, lastError, latestDeployment }progress0 未部署 → 20 pending → 50 uploading/deploying → 100 成功或失败。

复制给 AI 的轮询指令

部署我的游戏后,请轮询以下接口直到 progress=100:
  GET https://vibe.lumigrav.space/api/agent/deploy-status?project=<slug>
  请求头 Authorization: Bearer <API_Token>
若 deployed=true,把 url 给我;若 lastError 非空,读取 latestDeployment.logs
定位问题、修改代码后重新部署,再轮询,直到成功。