WebSocket在线五子棋实战:消息协议、房间状态机与断线重连 简介online_gobang 是一套面向 Web 开发学习者的实时在线五子棋项目围绕 Django 后端、WebSocket 双向通信与 Memcached 缓存展开适合想理解实时对战、会话管理与前后端协作的中级开发者参考。压缩包共 116 个文件约 322KB以 20 个 Python 文件承载后端视图、路由与配置逻辑19 个 JavaScript 文件负责棋盘交互和消息收发另含 gif、png 与 css 等界面素材以及 sqlite3 数据库和 txt、md 说明文档。已有 174 人学习说明其在实时 Web 小项目方向具备一定参考热度。读者可从中梳理 WebSocket 连接建立、落子消息同步、棋局状态缓存与静态资源组织的完整思路并借助现有目录结构快速定位后端服务、前端脚本与模板页面作为课程设计或小型实时应用的起步骨架。1. 从「两个人凑不齐一局棋」说起为什么在线五子棋必须用 WebSocket需求起点很朴素两个人不在同一个地方想下一盘五子棋。做成网页版在线五子棋打开链接就能进房间对弈落子实时同步不用注册不用装客户端。听起来简单真做起来第一个要拍板的就是通信方式。用 HTTP 轮询前端每秒发一次请求问「对方落子了吗」延迟肉眼可见服务端连接数还会随着房间数线性上涨用 WebSocket 建一条长连接服务端有落子就推客户端收到就画一局棋的交互才顺。这套方案用原生 JavaScript 写前端服务端跑 Node.js 加ws库不上框架、不引全家桶把「连接、房间、落子、判定」四件事拆干净适合拿来当课程设计或毕设的完整骨架也适合想把长连接这件事搞明白的开发者照着改。2. 总体架构与协议设计三层结构与一张定死的消息表动手写代码之前先把「谁负责什么」和「双方怎么说话」定下来。这两件事没想清楚后面每加一个功能都要回头改协议返工成本极高。这一章给出三层结构划分、六类核心消息的字段定义以及房间和座位的分配规则后面所有模块都按这套契约实现。2.1 三层结构静态资源层、连接会话层、对局逻辑层整个系统按职责切成三层层与层之间只靠函数调用和消息对象通信不互相穿透。静态资源层就是那几个文件index.html、board.js、style.css由一个极简的 HTTP 服务托管。这一层不关心棋局只负责把页面和脚本送到浏览器。连接会话层是 WebSocket 服务管连接的建立、心跳、断线识别、sessionId与连接的绑定关系。对局逻辑层管房间、座位、棋盘数组、回合、胜负是纯内存的不碰网络。这样切的好处很直接想换前端渲染方式只动第一层想加观战只在第三层加一个角色字段想把内存房间换成存储只改第三层的存储接口。刚上手时容易把「收到消息」和「处理落子」写在一个回调里几十行之后就没法看了。我一般会把目录定成这样online_gobang/ ├── public/ │ ├── index.html │ ├── board.js # 棋盘绘制、坐标换算、交互 │ └── net.js # WebSocket 封装、重连、消息分发 ├── server/ │ ├── index.js # HTTP WebSocket 启动入口 │ ├── room.js # 房间状态机、座位管理 │ ├── rule.js # 落子合法性、胜负判定 │ └── protocol.js # 消息 type 常量与校验 └── package.jsonprotocol.js单独抽出来是有原因的消息类型一旦散落在前后端各个文件里改一个字段名要全局搜索漏一处就是运行时静默失败。把常量集中定义前后端各引一份出错时至少能对得上号。2.2 消息协议一张 type 表定死前后端契约所有消息统一成一个信封结构type决定行为payload装数据seq用来做请求响应配对ts打时间戳方便排查时序问题。信封本身不承载业务语义业务全在payload里。type方向payload 关键字段说明joinC → SroomId、sessionId进房或重连sessionId为空表示新玩家joinedS → CroomId、seat、players分配座位seat为 1 或 2startS → Cfirst、board两人齐了开局first是先手座位号moveC → Sx、y、seq落子请求坐标是 0 到 14 的数组下标moveAckS → Cx、y、seat、next落子生效广播next是下一手座位gameOverS → Cwinner、line终局line是连成五子的坐标数组restartC → S无请求再来一局errorS → Ccode、msg非法落子、房间满、超时等信封长这样// server/protocol.js // 统一消息信封所有收发都走这个结构避免字段名各写各的 const MSG { JOIN: join, JOINED: joined, START: start, MOVE: move, MOVE_ACK: moveAck, GAME_OVER: gameOver, RESTART: restart, ERROR: error, PING: ping, PONG: pong }; function pack(type, payload {}, seq 0) { return JSON.stringify({ type, payload, seq, ts: Date.now() }); } function unpack(raw) { try { const msg JSON.parse(raw); if (!msg || typeof msg.type ! string) return null; return msg; } catch (e) { return null; // 脏数据直接丢弃不要让它进业务分支 } } module.exports { MSG, pack, unpack };pack里ts用服务端时间还是客户端时间要说清我用服务端时间客户端只做展示避免两边时钟不一致导致排查时对不上。unpack的try/catch不是防御性编程的摆设浏览器端一条格式错误的字符串就能让整个onmessage抛异常后面的消息全部丢失这个坑后面还会提。seq的用法是客户端每次发move自增一个序号服务端在moveAck里原样带回。客户端收到 ack 时比对序号只处理最新一笔旧响应直接丢。这是解决「快速连点导致状态错乱」最省事的办法比在客户端加锁简单得多。2.3 房间与座位roomId 分配和先手规则房间不做复杂匹配两种入口手动输入四位房间号或者点「快速开始」由服务端分配一个空房。房间号用四位数字字符串服务端维护MaproomId, Room创建时从 1000 到 9999 随机取一个没被占用的取不到就扩容到五位。这个策略在课程设计规模下完全够用不需要引入匹配队列。座位规则第一个进房的人拿 1 号位第二个拿 2 号位第三个人进来直接返回error码ROOM_FULL。先手固定给 1 号位不做随机理由是调试方便——你永远知道黑棋在左边。想加随机先手在start消息里把first改成随机结果就行前端读这个字段决定谁先落子不需要改别的。一个容易被忽略的细节房间什么时候销毁。两人都在正常结束房间保留 60 秒供「再来一局」如果一局结束后一个人直接关页面60 秒后房间连同棋盘一起清掉。定时器在房间创建时挂上每次有人重连就重置销毁前检查连接数。没有这个清理逻辑服务端跑一晚上内存里全是僵尸房间这是内存泄漏最常见的一种形态。提示房间号不要用自增 ID前端一眼就能看出总房间数也不利于手动输入的体验。四位随机串在这个场景下足够。3. 服务端对局核心房间状态机与落子裁决服务端必须是对局的唯一权威。前端可以预渲染、可以做乐观更新但最终以服务端广播的moveAck为准。这一章把房间的三态流转、落子裁决的完整校验链、断线重连的状态解耦讲透这三块是整份方案里最容易翻车的地方。3.1 房间状态机waiting、playing、finished 三态怎么流转房间只有三个状态但状态迁移的触发条件必须写死否则会出现「终局之后还能落子」「两人没齐就开始」这类问题。当前状态触发事件目标状态附加动作waiting第二名玩家joinplaying初始化棋盘广播startplaying收到合法move且未分胜负playing更新棋盘广播moveAckplaying收到合法move且连成五子finished广播gameOver记录lineplaying一方认输或断线超时finished广播gameOverwinner为对方finished任一方发restartwaiting清空棋盘等待双方确认finished60 秒无人重开销毁从Map中移除关键是restart的处理。我最初写成一方点「再来一局」就直接重置结果对面还没反应过来棋盘就空了。正确做法是restart只把当前玩家标记为「已准备」两个人都在准备集合里才真正迁移回waiting并广播新的start。这个确认机制多写十几行代码能省掉一堆「我还没看完呢」的投诉。// server/room.js const { MSG, pack } require(./protocol); const { checkWin, isValidMove } require(./rule); const STATE { WAITING: waiting, PLAYING: playing, FINISHED: finished }; class Room { constructor(id) { this.id id; this.state STATE.WAITING; this.board new Array(15).fill(0).map(() new Array(15).fill(0)); this.players new Map(); // seat - { conn, sessionId } this.turn 1; // 当前该谁走1 或 2 this.readySet new Set(); // 终局后点「再来一局」的座位 this.timer null; // 房间回收定时器 } // 玩家进房sessionId 命中已有座位则视为重连 join(conn, sessionId) { for (const [seat, p] of this.players) { if (sessionId p.sessionId sessionId) { p.conn conn; // 换连接座位和棋局不动 conn.send(pack(MSG.JOINED, { roomId: this.id, seat, resume: true })); return; } } if (this.players.size 2) { conn.send(pack(MSG.ERROR, { code: ROOM_FULL, msg: 房间已满 })); return; } const seat this.players.size 0 ? 1 : 2; this.players.set(seat, { conn, sessionId: sessionId || s_${Date.now()}_${seat} }); conn.send(pack(MSG.JOINED, { roomId: this.id, seat })); if (this.players.size 2 this.state STATE.WAITING) { this.state STATE.PLAYING; this.turn 1; this.broadcast(pack(MSG.START, { first: 1, board: this.board })); } } broadcast(text) { for (const { conn } of this.players.values()) conn.send(text); } }join里先做重连判断再判断人数顺序不能反。反过来的话掉线的人回来会看到ROOM_FULL——因为他的旧座位还占着位置。这类顺序问题在事件驱动的服务端里很常见写的时候要按「先认身份再分配资源」的思路走。sessionId的生成放在客户端存localStorage服务端只负责比对。这样刷新页面能自动回到原座位不用额外做登录体系。3.2 落子裁决回合校验、坐标合法性、终局判定一条链一次落子要过五道关任何一道不过都返回error且不修改任何状态房间处于playing状态。发送者在players里且占的是turn对应的座位。坐标是 0 到 14 的整数且目标格子为空。更新棋盘切换turn。调用checkWin命中则迁移到finished并广播gameOver。第 2 步是最容易漏的。前端做了「不该你走的时候禁用点击」但那是 UI 层的抓包直接发一条move就能越序落子。服务端不校验整局棋就废了。// server/rule.js const DIRS [[1, 0], [0, 1], [1, 1], [1, -1]]; // 横、竖、撇、捺四个方向 function isValidMove(board, x, y) { if (!Number.isInteger(x) || !Number.isInteger(y)) return false; if (x 0 || x 14 || y 0 || y 14) return false; return board[x][y] 0; } // 以落子点为中心沿四个方向双向计数凑够五子即胜 function checkWin(board, x, y, seat) { for (const [dx, dy] of DIRS) { const line [[x, y]]; for (const sign of [1, -1]) { let nx x dx * sign; let ny y dy * sign; while ( nx 0 nx 15 ny 0 ny 15 board[nx][ny] seat ) { line.push([nx, ny]); nx dx * sign; ny dy * sign; } } if (line.length 5) return line; // 返回连线坐标前端要用来高亮 } return null; } module.exports { isValidMove, checkWin, DIRS };checkWin返回坐标数组而不是布尔值是因为终局时前端要把五颗子高亮出来。这个决定在验收演示时很加分——面试或答辩现场评委问「你怎么证明判定是对的」直接看高亮连线的位置就行不用翻代码。方向数组里[1, -1]这一对容易被写成[-1, 1]数学上等价但双向扫描时sign把两个方向都覆盖了写哪一个都行。真正需要注意的是边界条件nx、ny的判断必须放在取board[nx][ny]之前短路求值保证不会越界。JavaScript 里board[15]返回undefined再取[0]会直接抛TypeError这条异常如果没被try/catch包住整个连接就断了。3.3 断线重连把会话身份和棋局状态拆开存断线是必然发生的切 Wi-Fi、笔记本合盖、手机锁屏。设计上要接受这个事实而不是假设连接一直活着。核心思路是身份和连接解耦。sessionId标识「你是谁」conn标识「你此刻从哪条连接来」。断线时只清conn不动座位和棋盘重连时用sessionId找到原座位把新的conn挂上去再把当前棋盘整盘推给客户端。// server/index.js const WebSocket require(ws); const { unpack, pack, MSG } require(./protocol); const rooms new Map(); const HEARTBEAT_MS 30000; function onConnection(conn) { conn.isAlive true; conn.on(pong, () { conn.isAlive true; }); conn.on(message, (raw) { const msg unpack(raw.toString()); if (!msg) return; if (msg.type MSG.JOIN) { const room rooms.get(msg.payload.roomId) || createRoom(msg.payload.roomId); conn.roomId room.id; room.join(conn, msg.payload.sessionId); } if (msg.type MSG.MOVE) { const room rooms.get(conn.roomId); if (!room) return; room.handleMove(conn, msg.payload); } }); conn.on(close, () { const room rooms.get(conn.roomId); if (room) room.markOffline(conn); // 只标记离线不立即判负 }); } // 心跳定时器清掉半开连接 setInterval(() { for (const conn of wss.clients) { if (conn.isAlive false) return conn.terminate(); conn.isAlive false; conn.ping(); } }, HEARTBEAT_MS);心跳这段解决了「TCP 连接还在但对方已经没了」的半开连接问题。没有它掉线玩家的座位会一直占着对手只能干等。isAlive标记配合ping/pong是ws库的标准做法terminate比close更干脆直接断 TCP不给对端磨蹭的机会。markOffline里挂一个 30 秒的计时器超时才判负玩家在这个窗口内回来就取消计时。这个宽限期的长度可以调课程设计里 30 秒够用实际产品可能要 60 秒以上。4. 前端棋盘与胜负判定Canvas 绘制和坐标命中的落地前端要做三件事把 15×15 的棋盘画出来、把鼠标点击换算成网格坐标、把服务端广播的落子画上去。看起来都是绘图真正花时间的是坐标换算和状态同步。这一章给出可复用的绘制函数、命中算法和交互闭环。4.1 Canvas 绘制与坐标命中像素到网格的换算棋盘用 Canvas 画不用 DOM 网格。理由是 15×15 一共 225 个交叉点DOM 方案要么画在格子里视觉上和五子棋规则不符要么用绝对定位铺 225 个点后者在移动端会卡。尺寸参数定死棋盘 15 条线格子边长 40 像素四周留 20 像素边距。总画布尺寸 20 × 2 40 × 14 600 像素。响应式靠 CSS 缩放 Canvas 的显示尺寸绘制坐标系不变。// public/board.js const SIZE 15; // 15 条线14 个格子 const CELL 40; // 格子边长 const PAD 20; // 边距 const CANVAS_PX PAD * 2 CELL * (SIZE - 1); // 600 // 网格下标 - 像素中心坐标 function toPixel(i) { return PAD i * CELL; } // 像素坐标 - 网格下标四舍五入并限制在合法范围内 function toGrid(px, py) { const i Math.round((px - PAD) / CELL); const j Math.round((py - PAD) / CELL); if (i 0 || i SIZE || j 0 || j SIZE) return null; return { x: i, y: j }; } // 点击容差距离交叉点超过这个像素就不算落子 const HIT_TOLERANCE CELL * 0.4; function onCanvasClick(evt, canvas) { const rect canvas.getBoundingClientRect(); // 显示尺寸可能被 CSS 缩放过要按比例还原到绘制坐标系 const scaleX canvas.width / rect.width; const scaleY canvas.height / rect.height; const px (evt.clientX - rect.left) * scaleX; const py (evt.clientY - rect.top) * scaleY; const grid toGrid(px, py); if (!grid) return; const cx toPixel(grid.x); const cy toPixel(grid.y); if (Math.hypot(px - cx, py - cy) HIT_TOLERANCE) return; // 离交叉点太远忽略 net.sendMove(grid.x, grid.y); // 只发请求落子等 ack 再画 }scaleX、scaleY这两行是整个前端最容易出问题的地方。Canvas 的width属性和 CSS 的width是两回事前者是绘制缓冲区尺寸后者是显示尺寸。在移动端用 CSS 把画布缩到 100% 宽点击事件的clientX是显示坐标不换算直接算会整体偏移。这个 bug 在桌面上测不出来一上手机就暴露属于典型的「上线才翻车」。HIT_TOLERANCE取CELL * 0.4是个经验值太小了要求点得极准太大了相邻交叉点会打架。0.4 倍格子边长意味着点击落在交叉点周围 16 像素的圆内才算数手感比较接近真实棋盘。绘制函数按「背景、网格、星位、棋子、最后一手标记」的顺序分层画每帧全量重绘。225 个点的绘制量在 60fps 下毫无压力不需要做局部刷新优化。4.2 胜负判定的双份实现前端预判与服务端裁决前端也写一份checkWin用途不是裁决是「落子后立刻高亮等 ack 期间给用户反馈」。服务端的判定才是最终结果。两份实现要保持逻辑一致这是这套方案里唯一允许的重复代码因为网络往返的几十毫秒里用户需要即时反馈。// public/rule.js —— 与服务端 rule.js 保持同一套方向数组 const DIRS [[1, 0], [0, 1], [1, 1], [1, -1]]; function scanWin(board, x, y, seat) { for (const [dx, dy] of DIRS) { const line [[x, y]]; for (const sign of [1, -1]) { let nx x dx * sign; let ny y dy * sign; while (nx 0 nx 15 ny 0 ny 15 board[nx][ny] seat) { line.push([nx, ny]); nx dx * sign; ny dy * sign; } } if (line.length 5) return line; } return null; }把这段代码复制一份而不是从服务端同步是因为前端不该为了判定一个五连去发一次网络请求。代价是两份代码要保持同步改方向数组或边界时两边一起改。实际项目里我会在rule.js顶部写一行注释标出版本号两边对齐避免只改了一边。棋盘在内存里也用二维数组board[x][y]0空、1黑、2白和服务端完全一致。收到moveAck就直接写数组再重绘不做增量 diff。数组和渲染状态始终单向流动消息 → 数组 → 画布绝不反过来从画布读状态。4.3 交互闭环悔棋、认输、再来一局这三个功能都不复杂但要处理「一方操作、另一方确认」的对称性。悔棋做成「请求 同意」两步。请求方发undoReq服务端把它转给对手对手回undoAck带同意或拒绝。同意后服务端回退两步自己的和对方的各一手保证还是自己走广播整盘棋盘。不做单步回退因为那样会打乱回合。认输是单向的直接发resign服务端把winner置为对方进入finished。认输不需要对手确认。再来一局就是前面说的restartreadySet机制两人都点了才重开。UI 上按钮点完变成「等待对手」对方点了之后两边同时收到start。// public/net.js —— 消息分发用一张表避免 if-else 越堆越长 const handlers { joined(msg) { seat msg.payload.seat; if (msg.payload.resume) renderFullBoard(msg.payload.board); // 重连整盘恢复 }, start(msg) { board msg.payload.board; turn msg.payload.first; render(); }, moveAck(msg) { const { x, y, seat: who, next } msg.payload; board[x][y] who; turn next; lastMove { x, y }; render(); }, gameOver(msg) { const { winner, line } msg.payload; highlightLine(line); // 高亮五连 showResult(winner seat ? 你赢了 : 你输了); }, error(msg) { toast(msg.payload.msg); } }; function dispatch(raw) { let msg; try { msg JSON.parse(raw); } catch (e) { return; } const fn handlers[msg.type]; if (fn) fn(msg); // 未知类型静默忽略兼容将来新增消息 }handlers用对象映射而不是switch好处是加消息只加一个键不改分发函数。dispatch里对未知类型静默忽略这样服务端先上线新消息、客户端还没更新时不会报错。5. 常见问题排查连接、会话与判定的五个坑这套方案跑通之后真正耗时间的是排查。下面五条是我在这个项目里实际踩过或者见过的按「现象 → 原因 → 解决」写遇到同类问题可以直接对照。5.1 连接与会话连不上、重连回空盘现象一页面打开一直显示「连接中」控制台报WebSocket connection to ... failed。原因通常有三个WebSocket 地址写成了http://而不是ws://开发时前端跑在 5500 端口、服务端跑在 3000 端口跨域被拦反向代理没配 upgrade 头。先看浏览器 Network 面板里那条请求的状态码101 是成功其他都是没连上。解决地址用location.protocol https: ? wss:// : ws://加location.host拼出来不要硬编码。跨域在服务端启动时把静态资源和 WebSocket 放同一个端口从根上绕开。代理场景要显式转发Upgrade和Connection两个头。现象二刷新页面后回到空棋盘对手那边显示我还在。原因sessionId没有持久化或者存了但join时没带上。服务端认不出这是同一个人就当成第三个玩家返回ROOM_FULL前端又静默吞掉了错误提示。解决sessionId在首次进房时生成并写入localStorage每次join都带上。服务端的重连分支要放在人数检查之前——这个顺序前面强调过。另外error消息一定要在 UI 上有反馈静默失败是排查成本最高的一类问题。5.2 对局逻辑重复落子、判定差一格、终局还能下现象三手速快的时候一次点击落下两颗子。原因有两个层面。前端层面点击处理和moveAck之间有时间窗用户连点两次两次都发出去了服务端层面第一笔落子后turn已经切换第二笔本该被回合校验拦住但如果校验写在异步回调里、或者用了setTimeout延迟处理就可能读到旧turn。解决前端在发出move后立刻把pendingMove置为{x, y}收到对应seq的moveAck才清除期间忽略所有点击。服务端把「校验 改棋盘 切回合」写成同步的一段中间不插入任何await。Node 是单线程的同步执行天然串行别自己把它拆成异步。现象四明明五颗子连成一线游戏没结束或者四颗子就判赢了。原因checkWin的方向数组漏了一个方向或者双向扫描时sign循环写成了只算正向。四子判赢多半是line.length 5被误写成 4或者初始点重复计数。解决写一个离线测试脚本把横、竖、撇、捺四个方向的五连、以及「四连 一头被堵」的负例各跑一遍。这个脚本二十行能省掉大量手动验证。line数组初始就包含落子点本身双向扫描时不要重复加。现象五分出胜负后还能继续落子棋盘越下越乱。原因handleMove只校验了state PLAYING但终局时状态迁移和广播是异步的广播还没发完新的move已经进来了。解决状态迁移放在改棋盘之后、广播之前同步完成。gameOver广播出去之前state已经是finished后续move一律被拒。另外前端收到gameOver后立刻给棋盘加一层pointer-events: none双保险。注意排查这类问题最有效的手段是在服务端每条消息进出时打一行带roomId、seat、type、seq的日志。没有日志的情况下靠猜一小时也定位不到。6. 验收与答辩用一局可回放的棋证明方案跑通了课程设计和毕设的验收环节最怕的不是功能少是讲不清楚。评委通常只有几分钟你打开两个浏览器窗口对下一局能证明的东西很有限能连上、能落子、能判胜负仅此而已。真正让人信服的做法是准备一份可控的验证清单每一项都有明确的观察点。我会按这张表走一遍验收项操作观察点连接建立两个浏览器窗口进同一房间两边都收到joined座位分别是 1 和 2开局同步第二人进房两边同时收到start棋盘为空先手为 1 号位回合约束非当前回合方在控制台手动发move服务端返回error棋盘不变越界与重复发送坐标(15, 0)和已有子的位置均返回errorboard未被修改胜负判定走一条固定的五连线路gameOver的line恰好是高亮的五个点断线重连一方刷新页面30 秒内回到原座位棋盘完整恢复终局锁定分出胜负后再发move被拒提示对局已结束房间回收双方关闭页面60 秒后服务端rooms.size减一这张表的价值在于每一项都能独立演示不依赖运气。答辩时不用现场下一整局挑「回合约束」和「断线重连」这两项演示前者证明服务端是权威后者证明状态管理做对了这两个点最能体现设计功力。演示前我会提前准备好几条可以直接粘进控制台的命令比如绕过 UI 直接往 WebSocket 发一条非法move// 在浏览器控制台执行模拟越序落子验证服务端校验是否生效 const ws window.__gameWs; // 提前把连接实例挂到 window 上方便调试 ws.send(JSON.stringify({ type: move, payload: { x: 7, y: 7, seq: 999 }, ts: Date.now() }));正常情况下应该看到error返回、棋盘不变。如果这条命令落下去了说明服务端的回合校验形同虚设这是评委最容易抓的点。提前把window.__gameWs挂好演示时不用现场翻代码找实例。还有一个小技巧把服务端日志按房间分组输出答不上来的时候直接切到终端看时序。日志里每条消息带上毫秒时间戳两个客户端的时间线一对谁先谁后一清二楚。这套日志我在开发中期才加上加完之后定位问题的速度大概快了三倍后悔药没处买。从那以后我每次做带长连接的项目都会在动手写业务之前先把「消息日志 一条可以在控制台直接注入的调试通道」这两件事做掉哪怕功能还没成形。它们的成本不到半小时但没有它们后面每一个 bug 都是一次盲人摸象。希望这套结构能帮到你把五子棋换成别的对局骨架基本不用动。本文还有配套的精品资源点击获取