
简介一套基于WebSocket的在线五子棋对战游戏完整源码面向想学习C网络编程与实时通信的开发者适用于课程设计或毕业设计参考。项目覆盖用户注册、登录、对战匹配、实时对战及实时聊天等核心流程能帮助理解WebSocket在游戏场景下的实际应用与多人在线同步机制。压缩包共33个文件大小6.13MB其中包括13个hpp头文件与gobang.cc等C服务端逻辑另有CSS/HTML/JS前端页面、SQL数据库脚本、makefile构建配置及说明文档结构清晰便于按模块研读。从服务端会话管理、匹配器、房间模块到数据库操作均有完整实现前端则涵盖登录、大厅与对局房间页面可整体运行调试也可单独抽取模块学习。目前已有406人学习下载适合作为C、HTML、CSS、JavaScript综合实践或个人项目参考并可在现有架构上继续扩展。1. 基于WebSocket的在线五子棋对战先搞懂它解决什么问题第一次做在线五子棋很多人以为难点在「五子棋判赢」——写个五子连珠判断就好。真正做完才发现判赢只是热身让你熬夜的是「在线对战」四个字两个人的棋盘怎么保持一致、掉线重连怎么办、同时点击同一格又怎么办。这个标题给的就是一套以WebSocket为通信底座、从规则到房间再到前端的完整设计源码服务端维护棋盘状态并通过WebSocket推送落子前端负责绘制与交互。适合拿来写课程设计、毕设或者只是想搞清WebSocket长连接怎么落地到游戏场景的人。我会按自己实现这个方案的路径把规则、协议、联调和踩坑一次讲清楚。2. 对战核心五子棋规则与赢棋判定的代码化先把这个棋盘地基讲清楚。很多人一上来就拖一个15x15的table标签边画边写判断结果后面要接WebSocket时发现棋盘数据结构没法序列化只能返工。常见做法是写一个独立的游戏引擎模块前端和服务端共用同一套逻辑。这样协议里传的只是一个坐标数组而不是一堆DOM事件。2.1 棋盘存储一维数组是更省事的选择棋盘的直观设计是二维数组board[y][x]但在实际做WebSocket联机时我更推荐一维数组cells下标用idx y * size x计算。原因有三个一维数组序列化成JSON后就是一个普通数组广播给前端后直接遍历渲染复制和回滚用slice()一次搞定二维数组要写深拷贝下标计算少一层嵌套不容易出现board[x][y]写反的临界错误。const BOARD_SIZE 15; const EMPTY 0; const BLACK 1; const WHITE 2; class Board { constructor(size BOARD_SIZE) { this.size size; this.cells new Array(size * size).fill(EMPTY); } inBounds(x, y) { return x 0 x this.size y 0 y this.size; } get(x, y) { return this.cells[y * this.size x]; } set(x, y, player) { if (!this.inBounds(x, y)) return false; if (this.cells[y * this.size x] ! EMPTY) return false; this.cells[y * this.size x] player; return true; } }这段代码里inBounds是边界闸门凡是落子、判赢、AI评分都要先问过它。set返回布尔值表示这一手是否被接受这是服务端权威校验的第一道关卡。size默认15如果你想做13路或19路对弈直接传size就行不用改其他逻辑。这里有一个很隐蔽的坑有些新手会把下标写成x * size y导致棋盘发生转置黑子落在屏幕右侧服务端却记录在上方。我建议在get/set内部统一用y * size x一旦确定就不要变。坐标转换全部收敛在这个类里外面只传x和y。2.2 落子合法性先校验再落子棋盘只是存储对弈流程需要另一个类来管。GomokuGame负责维护当前轮到谁、落子序列、胜负结果。这一步要做的就是「先校验再落子」而不是客户端画了一颗子再告诉服务端。class GomokuGame { constructor(players) { this.board new Board(); this.players players; this.turn players[0].color; this.moveHistory []; } place(x, y, color) { if (color ! this.turn) { return { ok: false, err: noway }; } if (!this.board.set(x, y, color)) { return { ok: false, err: cell }; } this.moveHistory.push({ x, y, color }); this.turn color BLACK ? WHITE : BLACK; const win this.checkWin(x, y, color); return { ok: true, win, board: this.board.cells }; } }place是服务端唯一接受落子的入口。第一个if判断是否轮到这个颜色第二个if交给Board的set做坐标和占用校验。两个校验都过了才把落子记录推入moveHistory切换轮次然后判赢。注意moveHistory不是可有可无后面的悔棋、复盘、断线重连都靠它。参数说明players数组里每个元素形如{ id: u1, color: BLACK }颜色用数字1和2表示。为什么不直接用字符串black因为数字传输体积更小协议解析时少一次字符串比较。err字段为了给前端提示noway表示「还没轮到你」cell表示「这里已经有子」。如果不做禁手规则这一节就是全部落子逻辑。要做禁手的话需要在黑棋落子后额外调一个isForbidden(x, y)函数检查三三、四四和长连逻辑量会翻倍。大多数在线对战为了爽快默认关闭禁手标题并没有强调标准五子棋规则所以先用无禁手版本是合理的。2.3 判赢算法四方向偏移量与边界判断判赢函数是所有对弈逻辑里最容易写错的一环。常见错误是只数右方和下方漏了对角线或者忘了边界越界读数组拿到undefined。正确做法是定义四个方向向量从当前落子点分别向正反两个方向扫描同色棋子总数大于等于5就判胜。const DIRECTIONS [ { dx: 1, dy: 0 }, // 水平 { dx: 0, dy: 1 }, // 垂直 { dx: 1, dy: 1 }, // 对角线 { dx: 1, dy: -1 }, // 反对角线 ]; checkWin(x, y, color) { for (const { dx, dy } of DIRECTIONS) { let count 1; let tx x dx, ty y dy; while (this.board.inBounds(tx, ty) this.board.get(tx, ty) color) { count; tx dx; ty dy; } tx x - dx; ty y - dy; while (this.board.inBounds(tx, ty) this.board.get(tx, ty) color) { count; tx - dx; ty - dy; } if (count 5) return true; } return false; }DIRECTIONS四个向量的写法是固定的。注意反对角线的dy为-1因为屏幕坐标系y轴向下从左上到右下的斜线需要dx1、dy1从右上到左下的斜线需要dx1、dy-1。一开始记不清没关系在格子上画一下就能验证。判赢时先向正方向累加再回头向反方向累加当前点本身只计一次所以count初始为1。count 5是五子棋的基础规则。无禁手下允许长连所以大于5也算赢。如果你想做严格禁手这段要改成精确等于5禁手检测另写。这个方法在AI评分时也能复用把返回boolean改成收集连子长度就能作为局面估值信号。这一章实现的Game类和Board类是纯JavaScript不依赖任何DOM和WebSocket对象所以前后端可以共同引用。我在实际项目中通常把这个文件放到shared目录前端打包时直接import后端require从源头避免「前端一套规则、服务端一套规则」的分裂。如果你用的语言不是JS按这个结构搬到Python或Java也非常容易数据结构都是数组和对象。3. WebSocket协议与对战房间把「在线对战」四个字做实规则引擎能单机跑之后接下来才是重头戏。在线对弈本质上是两个客户端共享一个棋盘状态WebSocket是这个共享管道的首选因为它是长连接服务器可以主动把对方的落子直接推给你不用像HTTP轮询那样让客户端每隔几秒拉一次。轮询也不是不能做但实时性差而且大量无效请求会把服务器资源吃干净。下面按从连接到房间的顺序讲。3.1 用ws还是wss本地开发与公网部署的差异先选连接协议。本地联调用ws://就行比如ws://localhost:8080。一旦要把游戏挂到公网或者放在HTTPS页面下面就必须用wss://否则浏览器会直接拦截混合内容。wss和ws的区别就是多了一层TLS加密URL前缀不同代码逻辑完全一样。不过在服务器上要准备证书并且让反向代理把Upgrade头正确地透传。WebSocket和HTTP的对比我用一张表总结后面联调时你还会回来参考它。对比项HTTP轮询WebSocket长连接连接维持每次请求都带完整头部握手后保持一条TCP连接服务端推送只能客户端先请求服务端可随时下推实时性取决于轮询间隔毫秒级直接到达典型开销请求头响应头重复传输小帧数据头部极小适用场景低频拉取对弈、聊天、行情为什么五子棋适合WebSocket它不需要极高频推送但消息必须「有且仅有一次」地送达。HTTP轮询做不到服务端主动推送客户端必须不停问「有没有新落子」体验很差。WebSocket则是服务端在落子完成后直接推给对端配合消息确认机制能保证对弈流程顺畅。3.2 房间机制房主、加入、准备与开局消息流有了连接下一步是建立房间。五子棋是对战游戏不可能让两个陌生连接直接落子需要有一个中间状态把双方拉到同一张桌子上。常见做法是A创建房间拿一个房间号B输入房间号加入双方点准备服务端确认两人都准备后创建对局并广播开始消息。class Room { constructor(id, owner) { this.id id; this.owner owner; this.players []; // 正式玩家最多2人 this.spectators []; // 观战者 this.state waiting; // waiting - ready - playing - end this.game null; } }Rooms集合可以用MaproomId, Room来维护。创建房间时new Room(id, ws)加入房间时检查players长度是否小于2小于就push等于2就只允许进spectators。owner字段只在房间管理界面有用比如踢人和解散房间它不代表执黑开局时黑白随机分配更公平。state字段是房间状态机后续所有消息分支都先查state比如state不是playing时收到move消息直接按非法操作处理。消息流的时序是这样的客户端发起join后服务端先把新玩家挂到Room.players然后向房间内所有人广播player:joined如果正好凑齐两人则自动把房间状态切成ready并广播player:ready两人都点准备后广播game:start同时创建GomokuGame实例。这里的关键是所有状态变更都由服务端发起客户端只响应广播不会出现两个客户端各自动维护一个房间状态然后互相打架的问题。3.3 消息协议字段设计type、data与状态机联机游戏必须有显式的消息协议哪怕两个人临时写死了字段也要统一成一套结构。我常用的最小协议是{ id: a1f0-..., type: game:move, data: { roomId: R1024, x: 7, y: 7 } }服务端处理完会回{ id: a1f0-..., type: game:move:ok, data: { board: [0, 0, 1, 0], next: 2, win: false } }这里id不是装饰。WebSocket本身没有请求-响应对应关系也没有消息去重。如果客户端断线重发或者对方手点两次服务端没法判断这是「同一件事」还是「两次不同的落子」。给每条消息一个唯一id服务端把最近处理过的id放在一个固定大小的Set里遇到重复id直接丢弃这样就给协议加了幂等保护。这种状态机设计思路在《设计模式与游戏完美开发》这类游戏编程书里常被强调实战里确实是防止非法消息的第一道闸门。type字段用来驱动状态机。服务端handleMessage里根据type分发客户端onmessage里也根据type走不同回调。data里的roomId让消息能定位到所属房间x/y是落子坐标board是服务端权威棋盘。注意不要把board放在每次广播里会造成大量重复数据一般只在开局、移动成功、重连同步时传完整棋盘中间过程传坐标就行。我这个示例为了直观每次都传了完整棋盘数据量也不大15x15225个数字可以接受。3.4 心跳机制基于WebSocket的ping/pong保活与断线重连WebSocket连接不是永远稳定的。网络设备静默丢包、服务器半开连接、代理超时都会让一条「看起来还活着」的连接实际上已经坏死。这时候如果不做心跳客户端就永远等不到对端消息玩家还以为对方在思考。所以必须实现常说的WebSocket心跳机制客户端定时发ping服务端超时未收到就断开连接客户端再自动重连。服务端用Node.js的ws库最省流量的方式是直接调用底层ping/pong帧不用走业务消息const heartbeatInterval 30000; const timeoutInterval 60000; function heartbeat() { for (const ws of wss.clients) { if (ws.isAlive false) { ws.terminate(); continue; } ws.isAlive false; ws.ping(); } } const timer setInterval(heartbeat, heartbeatInterval); wss.on(connection, (ws) { ws.isAlive true; ws.on(pong, () { ws.isAlive true; }); });heartbeatInterval是心跳间隔30秒是常见选择timeoutInterval是超时时间但实际不会单独使用因为错过的连接的isAlive还是false下一轮就被terminate。如果你的用户网络差建议把心跳间隔缩短到15秒同时客户端要确认自己发ping之后收到了pong否则连接名存实亡。客户端侧的处理是onclose后延迟1秒重连重连地址带token服务端靠token找回房间。注意心跳既能防代理断开也能检测服务器进程崩溃。它不解决数据补偿只解决「连接是否还活着」真正丢的棋盘数据要靠重连后重新sync。4. 服务端与前端联打通关最小可跑源码的骨架协议定了接下来就是把房间管理和广播逻辑真正跑起来。这一章不追求高并发目标是在本地打开两个浏览器能完成「建房 - 加入 - 对弈 - 判胜」全流程。下面给出的是我常用的最小闭环拿到后能直接改造成自己的源码。4.1 服务端选型Node.js ws还是Go gorilla/websocket在线五子棋的技术栈有很多种选法但最常见的组合是前端原生WebSocket加后端Node.js的ws库。原因很简单前后端同一种语言第2章共享的规则引擎可以直接复用不用维护两套代码。如果你更看重性能可以选Go的gorilla/websocket但业务代码组织和热更新不如Node.js方便Python的websockets库也不错就是asyncio对新手的心智负担略高。方案开发效率性能易踩坑点Node.js ws高共享JS规则足够支撑小规模对弈回调地狱需要整理消息分发Go gorilla/websocket中需要写结构体映射高并发连接更省内存跨域与接口定义更严格Python websockets中asyncio学习曲线陡中事件循环阻塞会卡全房间我的选择是Node.js ws因为标题是「设计源码」重点在把架构讲清楚而不是制造性能瓶颈。ws库是一个零依赖的WebSocket实现安装后直接开Server适合当教学骨架。4.2 Node.js服务端把房间管理和广播跑起来服务端入口可以长这样const { WebSocketServer } require(ws); const { randomUUID } require(crypto); const wss new WebSocketServer({ port: 8080 }); const rooms new Map(); wss.on(connection, (ws, req) { ws.id randomUUID(); ws.roomId null; ws.isAlive true; ws.on(pong, () { ws.isAlive true; }); ws.on(message, (buf) { const msg JSON.parse(buf.toString()); handleMessage(ws, msg); }); ws.on(close, () { if (ws.roomId) { const room rooms.get(ws.roomId); room?.removePlayer(ws.id); } }); });connection回调里给每个连接赋予唯一id初始化roomId为空。message回调统一走handleMessage不在这里写业务逻辑为了后续可维护。close回调负责从房间里移除玩家这是断线清理的第一道门。注意rooms这个Map承担了所有房间状态进程重启就没了所以生产环境要落地到Redis或数据库小项目先用Map没问题。handleMessage的分发逻辑大概是function handleMessage(ws, msg) { switch (msg.type) { case room:create: createRoom(ws, msg.data); break; case room:join: joinRoom(ws, msg.data); break; case player:ready: playerReady(ws, msg.data); break; case game:move: handleMove(ws, msg.data); break; default: ws.send(JSON.stringify({ type: error, data: { reason: unknown } })); } }每一个case对应一个函数函数内从rooms.get(data.roomId)拿房间再调用对应的动作。这些动作都改服务端状态改完向房间成员广播。这里有一个重要参数广播前检查ws.readyState 1也就是OPEN状态因为有些连接已经半死直接send会抛异常。这个检查虽然小但能避免很多线上报错。4.3 前端原生WebSocket封装onmessage路由与自动重连前端如果引入socket.io确实能省掉心跳和重连但也会把协议裹进私有格式。为了把WebSocket这条链路看清楚原生WebSocket更合适。我习惯封装成一个WsClient类把连接、发送、路由收拢到一起class WsClient { constructor(url, token) { this.url ${url}?token${token}; this.reconnectDelay 1000; this.handlers {}; } connect() { this.ws new WebSocket(this.url); this.ws.onopen () this.send({ type: player:hello }); this.ws.onmessage (ev) this.route(JSON.parse(ev.data)); this.ws.onclose () setTimeout(() this.connect(), this.reconnectDelay); } send(obj) { if (this.ws this.ws.readyState WebSocket.OPEN) { this.ws.send(JSON.stringify(obj)); } } route(msg) { const fn this.handlers[msg.type]; if (fn) fn(msg.data); } on(type, fn) { this.handlers[type] fn; } }这里有三处关键设计url带着token重连后服务端可以识别同一个玩家send里先判断readyState避免连接还没建好就往空管道里发数据route把消息按type分发到具体回调前端视图更新只在回调里做保持单向数据流。reconnectDelay设1秒是合理的太快会在服务端没恢复时疯狂重连太慢影响玩家体验。4.4 联调关键点先看Network面板再谈逻辑前后端都写好后第一件事不是在编辑器里盯代码而是打开浏览器开发者工具切到Network面板找到WS标签页刷新页面后再落一颗子。这里能看到每一条收发消息包括握手请求、ping/pong帧和业务消息。如果消息没显示说明连接就没建立起来这时候去看Console里的CORS错误或404。常见的联调翻车有三类。第一服务端地址拼错前端用了http而服务端要的是ws解决方法是前端动态拼接如${location.protocol https: ? wss : ws}://${location.host}/ws。第二ws握手被反向代理挡了只能看到101响应迟迟不来去检查代理配置的Upgrade头。第三客户端与服务端消息字段对不上比如服务端用roomId前端用roomID这种低级错误光看代码很难发现在WS帧里一眼就能看出JSON字段名。所以我把Network面板视为联调的第一工具而不是console.log。5. 避坑与排查WebSocket五子棋最容易翻车的5个位置这一章记录我在实际跑这个方案时被绊过的位置。很多都是「现象很玄学原因很简单」。下面按影响排序前两个最影响体验后三个最影响正确性。5.1 连接被nginx断开代理超时与心跳参数现象游戏进行到一半客户端WebSocket在60秒左右精确断开onclose触发后重连又正常。如果你把重连关掉会发现连接总是死在静默期。原因服务器前面挂nginx做反向代理nginx默认proxy_read_timeout是60秒。客户端和服务器之间没有消息往来时nginx认为连接空闲主动切断。这不是WebSocket本身的问题而是代理层对长连接不友好。解决在nginx的location配置里打开Upgrade透传并调大超时时间。下面这段配置是完整可用的location /ws { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_read_timeout 600s; proxy_send_timeout 600s; }四个proxy_set_header和两个timeout缺一不可。配置完记得reload nginx再测试。同时客户端依然要保留30秒一次的心跳因为即使nginx不掐运营商网络也可能掐掉空闲TCP连接。这条配合第3.4节的心跳才算完整。5.2 重复点击导致重复落子幂等校验现象玩家快速双击鼠标自己的棋盘上出现两颗同色子对手棋盘上也多了一颗整局从第一步就错位。原因落子接口没有做「轮次校验」和「格子占用校验」。前端第一次点击发出move消息第二次点击其实已经不该发但因为按钮没被禁用消息照样发过去了。WebSocket消息是异步的第二次消息到达服务端时轮次已经切到对手如果服务端不校验就会误判为两次落子。解决前端在发move后立刻置一个pending状态收到game:move:ok或对手广播后再解除服务端在handleMove入口加一次强校验const room rooms.get(msg.data.roomId); const game room.game; if (!game || msg.data.color ! game.turn || game.board.get(msg.data.x, msg.data.y) ! EMPTY) { ws.send(JSON.stringify({ type: game:move:err, data: { reason: illegal } })); return; }注意color这个字段不应该由客户端说了算而是服务端从连接token对应的玩家信息里读出来。这样就算客户端伪造color也不可能绕过轮次。前端锁只是体验优化服务端校验才是底线。5.3 棋盘数据不一致服务端权威校验是底线现象两边棋盘偶尔不一样A玩家看到自己已经连成五子B玩家这边却还差一格。把锅甩给网络延迟似乎合理但本质是状态同步模型错了。原因如果采用「客户端本地先落子再把结果告诉服务端同步」的方案两个客户端各自维护一个棋盘只要消息乱序或丢失状态就会分叉。WebSocket保证不了应用层消息的不丢失重连后也不可能自动补齐。解决改成服务端权威模型。客户端永远不直接修改棋盘只发送「落子请求」服务端GomokuGame执行落子计算新棋盘把结果广播给两个玩家。客户端收到广播后直接覆盖本地棋盘。这样棋盘就只有一个来源不存在两个客户端各自为政的问题。棋盘数据序列化后只有225个数字每次广播完整棋盘都不会有性能压力。5.4 断线重连后房间状态丢失现象玩家网络闪断自动重连成功但房间里只剩自己一个人房主丢失对局无法继续。原因服务端在connection里用ws.id标识连接断开后这个id作废新连接是一个全新的id房间里的players数组还存着旧引用。重连后服务端不认识这个新连接自然也不会把它加回房间。解决用业务层的sessionId/token做身份在WebSocket握手URL里带过去。重连时服务端在connection回调里解析query参数如果找到未过期的会话就把这个新ws加入原来的房间并同步最新棋盘。示例const wsUrl new URL(req.url, http://localhost); const token wsUrl.searchParams.get(token); const player sessions.get(token); if (player player.roomId) { const room rooms.get(player.roomId); room.reattach(player.id, ws); ws.send(JSON.stringify({ type: game:sync, data: { board: room.game.board.cells } })); }reattach负责把旧player.id标记为新连接并把房间里的旧连接替换掉。这之后房间状态才真正活过来。这条是让在线对弈「看起来专业」和「只是demo」之间最大的分水岭。5.5 广播风暴消息发给了不该收的人现象开两个房间A房间落子B房间也收到同样的move广播。所有客户端狂刷屏逻辑乱成一锅粥。原因新手拿到wss.clients后习惯用wss.clients.forEach广播所有连接这个集合里包含所有房间的人自然就把消息泄给了无关连接。观战模式上线后这个坑会尤其明显因为观战者、玩家、其他房间全部混在一起。解决给每个ws保存roomId广播时只遍历room.players和room.spectators。最简单的过滤方式function broadcastToRoom(room, payload) { const data JSON.stringify(payload); for (const player of room.players) { if (player.ws player.ws.readyState WebSocket.OPEN) { player.ws.send(data); } } }这个方法在后面做观战时也要用观战者只收board广播不收操作请求。按房间过滤是最基础的隔离如果房间数量大了可以再引入订阅表或Redis频道但小项目这一步足够。6. 从能玩到好用观战、悔棋与AI落子两个进阶技巧基础对战跑通之后想拿出去给朋友玩至少还差悔棋和AI陪练两个功能。第4章的源码骨架留着这两个功能不需要改动协议大结构。6.1 悔棋与撤销用操作序列而不是快照回滚我第一次做悔棋时存的是整盘棋的快照悔棋就恢复上一帧。后来发现快照占用内存不说和WebSocket增量广播也配合不好两个客户端各恢复各的很快又不一致。教训是用操作序列。前面moveHistory里已经记录每一步的{x, y, color}悔棋时服务端从栈顶弹出一个记录将该格置为EMPTY再广播一条game:undo消息告诉两端重绘这个位置。为了防止「瞬间悔棋到开局」通常要得到对方同意所以协议里得有game:undo:req和game:undo:ok两个消息房间挂一个pendingUndo字段暂存待确认的请求。这里我栽过跟头请求消息没有带id对方连点两次确认同一手被回滚了两遍。所以进阶功能也要沿用第3.3节的消息幂等设计。6.2 接入AI落子把判赢算法反向变成评分函数想单机练手或者好友不够时填位置可以写一个初级AI。不用急着上神经网络把第2.3节的判赢方法反着用就是一道很好的启发。对每个空位向四个方向扫描旁边同色棋子的数量按「连二、连三、冲四、活四」给不同分值然后分别评估自己的候选点和对手的候选点取最高分落子。这个方案十分钟就能写完并且能跟新手有来有回。做得再深一点就是rapfi五子棋这类引擎的评估与搜索思路了从评分表到alpha-beta剪枝是一条清晰的路。但我不建议一上来就把AI做重先让AI能接在Room.players里参与和人类相同的move流程才是正确的集成方式。落定这两个功能后再回头看会发现当初坚持服务端权威模型和消息幂等给后面省了很多力气。我自己的习惯是对战逻辑里每一处都不允许「可能」都能追溯到一条明确的广播消息。这次教训是协议里从第一天带上requestId和房间状态比事后补要省太多时间。希望帮到你。本文还有配套的精品资源点击获取