真实局面留在服务器端
迷雾国际象棋给国际象棋增加了一条隐藏信息规则:每一方只能看到己方棋子可及的格子。实现问题在于这条规则在哪里运行。在 Mistboard 上,它运行在服务器端,所以浏览器收到的是一个 PlayerView,而不是盖着迷雾图层的完整棋盘。
这组三联棋盘就是整个架构的缩影。中央棋盘只存在于服务器上。白方与黑方各自收到不同的投影视图,任何一份投影都不是用视觉图层遮住完整真实局面。
规则很简单:只计算一次真实状态,再为每个席位投影其获准看到的视图,并在对局结束前将完整事件日志保持私密。
这一条边界同时支撑实时玩家对战、引擎对局、校准、赛事和复盘。本文聚焦面向玩家的实时房间:每个浏览器会收到什么、谁可以接收,以及对局记录何时公开。
视图如何计算
对玩家而言,这条边界就是 PlayerView:该席位可见的格子、可见棋子、合法着法、状态和时钟。可见范围之外的对方棋子不是被藏在字段里,而是根本不存在于数据中。
// packages/game/src/variants.ts (condensed)
// 1. Which squares can this player see?
export function fogVisibleSquares(state, player) {
// every square one of your own pieces stands on...
const visible = new Set(ownPieceSquares(state.board, player));
// ...plus every square one of your pieces could move to or capture on
for (const move of getVisibilityMoves(state, player)) visible.add(move.to);
return [...visible].sort();
}
// 2. Keep only the pieces standing on those squares.
export function boardVisibleTo(board, visibleSquares) {
const visible = new Set(visibleSquares);
const playerBoard = {};
for (const [square, piece] of Object.entries(board))
if (piece && visible.has(square)) playerBoard[square] = piece;
return playerBoard;
}
// 3. Assemble the view that gets sent.
getPlayerView(state, player) {
const visibleSquares = fogVisibleSquares(state, player);
const board = boardVisibleTo(state.board, visibleSquares);
return {
board, // only the pieces kept by step 2
visibleSquares, // step 1: which squares render clear vs. fogged
legalMoves: yourTurn(state, player) ? getFogMovesForPlayer(state, player) : [],
status, perspective: player, moveNumber, clock,
lastMove, // your own last move; the opponent's is stripped
};
}关键在于依赖方向。客户端因为收到可见性掩码而能够渲染迷雾,却无法通过移除迷雾来恢复从未发送给它的棋子。
示例数据载荷
实时走子流使用 event-appended,每步发送一帧。下面是上方局面中发给白方的载荷,已缩减为关键字段:
{
"type": "event-appended",
"roomId": "mb-demo-room-001",
"seat": "white",
"seq": 6,
"state": {
"board": {
"a1": { "color": "white", "role": "rook" },
"e4": { "color": "white", "role": "pawn" },
"e5": { "color": "black", "role": "pawn" },
"f7": { "color": "black", "role": "pawn" }
},
"visibleSquares": ["a1", "a2", "a3", "..."],
"legalMoves": [{ "from": "b1", "to": "a3" }, "..."],
"status": { "type": "playing", "turn": "white" },
"perspective": "white",
"clock": { "...": "current clock state" }
}
}核心字段:seat 标识接收者,seq 为数据流排序,state.board 是脱敏后的棋盘,state.visibleSquares 是清晰区域与迷雾区域的掩码,state.status 携带规范的轮次与结果状态。
如果新增事件对该席位可见,这一帧会包含一个经过过滤的 event。如果走子被隐藏,event 会被省略,但投影后的 state 仍会推进。玩家知道一回合已经发生,却不知道迷雾中发生了什么。
首次连接、显式恢复和最终重新同步仍会使用快照。快照携带客户端初始化所需的过滤事件历史,因此比逐步帧更大。
玩家走子
走子请求只有坐标:
// client -> server, sent on player's move
{ type: 'move', from: 'e2', to: 'e4' }服务器根据规范状态验证请求,执行走子,追加事件,再投影下一份视图。客户端永远不负责判断是否存在隐藏信息、是否发生了不可见走子,或对局是否结束。
按席位控制的实时房间
实时对局期间,服务器只向两个对局席位发送游戏数据。每步之后,它分别为白方和黑方投影一份视图,再将每份视图只发送给已经证明自己控制该席位的套接字。
// live room gate (condensed)
export const seat = verifySeatClaim(socket, room);
if (!seat) {
closeSocket(1008, 'private room');
return;
}
send(projectPlayerView(room.gameState, seat));席位证明
套接字只有在证明自己控制白方或黑方席位后,才能获得实时房间数据。匿名席位使用随机不记名令牌;服务器保存 SHA-256 令牌哈希,并以恒定时间比较提交的令牌。
账号席位
已登录席位会在席位声明之上增加账号会话检查。令牌证明该浏览器可以取回席位;会话则证明账号仍与席位分配相符。
没有实时观战视图
非对局玩家不会获得实时观战投影。没有有效席位的套接字会在房间数据发送前被拒绝,而实时回放端点会一直返回 403,直到对局进入终局状态。
赛后复盘
对局进入终局状态后,隐私规则随之改变。结果产生后,房间不再拒绝非对局玩家,游戏页面则成为持久的公开复盘界面。
GET /room/abc123 active game, seat token required
GET /api/games/abc123/events active game, 403
GET /game/abc123 finished game, public review
GET /room/abc123 finished game, opens without a seat观众在对局进行时打开房间,看不到棋盘。结果产生后,同一个人可以打开已结束的游戏页面并检查事件日志。这就是产品规则:决策仍在进行时保持私密,记录确定后可以复盘。
这种区分对计分对局很重要。计分结果可以指向一盘公开的已完成对局,同时不让非对局玩家接触实时隐藏信息。
它也让重连与复盘建立在同一基础上。实时重连从事件日志重建过滤后的玩家视图;隐藏信息限制失效后,赛后复盘使用同一份日志。
范围与验证
这并不是一项完整的反作弊声明,而是该架构能够证明的、更具体的完整性保证:实时对局期间,隐藏真实状态不会发送到未经授权的浏览器路径;对局结束后,记录可供复盘。
匿名休闲席位依靠不记名令牌,并不具备账号级身份保证;隐藏信息游戏也没有实时观战模式。
Mistboard 用 WebSocket 与载荷回归测试覆盖这条边界:测试会执行真实走子,并断言每个席位实际收到的字节。
这就是 Mistboard 守住的界线:对局期间,浏览器端没有可以揭开的真实局面;对局结束后,则有公开记录可供检查。
接下来去哪
来玩 Misty 的迷雾国际象棋,或阅读面向玩家的规则文章,了解同一套视野模型。