临近暑期实习季,我看过身边不少准备找实习的同学,把大量时间花在“再做一个小游戏”“再刷一遍LeetCode”上面。技能栏里写着“熟悉TCP/IP”“熟悉C#”,但真被问到“你的项目里为什么用TCP而不是UDP?”“Socket收到消息怎么处理半包?”时,往往只能答到概念层。原因不在于不努力,而在于很多项目根本没有让“网络”成为被真实验证过的模块。
一个 Unity + C# Socket 服务端做的 TCP 多人联机合作生存游戏,恰好能把这块短板补上。它有客户端表现、有服务端逻辑、有协议设计、有状态同步、有异常恢复,每一个都是实习面试里能拿出来深聊的点。这篇文章不会教你做一个很复杂的商业化联机游戏,而是围绕“27届求职实习”这个目标,讲清楚这类项目该怎么做、怎么讲、怎么通过它证明自己的工程能力。
1. 动手之前,先想清楚这个项目到底在证明什么能力
1.1 为什么不是单机游戏,也不是随便一个 Web 项目
很多同学的简历里有一个单机游戏项目,比如坦克大战、跑酷、小解谜。这类项目练的是玩法、渲染、UI、状态管理,它们很重要,但基本不涉及网络边界。面试官问“两台电脑怎么通信”的时候,单机项目无法提供任何证据。
反过来,一个标准的 Web 项目(比如博客系统、后台管理)练的是 HTTP 请求、数据库、前端展示,它和实时通信的差异也很明显。Web 项目大多数是一次请求、一次响应,断线了用户刷新页面就好;而实时多人联机要求一条长连接上持续地传递状态,它考查的不只是“会不会写接口”,而是能不能设计通信协议、管理多个客户端连接、保证消息完整和有序、处理中途断开。
多人联机项目正好处在两者之间:它既有游戏客户端该有的表现层和交互层,又有服务端该有的连接管理和消息处理。对于找实习的同学来说,这种“跨层”属性非常有价值,因为面试官能顺着这个项目同时考察你的 C# 基础、网络基础、线程模型和架构意识。
1.2 合作生存游戏的网络需求,刚好卡在一个合适的位置
为什么是合作生存,而不是做一个竞技射击或者 MOBA?原因很简单:这类玩法的网络需求相对清晰,开发量也比较可控。
合作生存游戏通常需要若干个玩家一起采集资源、打怪、维持生命值或饥饿度。它要求所有玩家看到的世界基本一致:你看到一个箱子没有打开,别的玩家也应该看到它没打开;你看到 BOSS 血量剩下 10%,别人不应该看到 50%。这种强一致的诉求,正好适合用 TCP 起步。
TCP 是可靠传输,面向连接,数据有顺序保证。对合作生存这类游戏来说,位置和事件如果丢包乱序,玩家体验会非常糟糕。UDP 虽然延迟更低,但需要自己做丢包重传、顺序整理和连接状态维护,开发成本明显更高。我并不是说 TCP 适合所有游戏,FPS、大逃杀这一类比拼反应速度的玩法往往需要 UDP,但对于一个求职实习项目,先用 TCP 把整套网络链路跑通,是性价比最高的选择。
而且,TCP 和 Socket 的知识密度刚好够面试深挖。三次握手、四次挥手、粘包拆包、心跳机制、半包处理、线程安全,这些全是常考题。当你手里有一个真实项目时,这些问题就不再是背概念,而是可以结合代码和运行日志去解释。
1.3 项目三层结构:Unity 客户端、C# Socket 服务端、协议层
这类项目最容易犯的错误,是觉得“服务端就是某个类里的一个方法”。实际上,服务端应该是一个独立进程,它可以跑在自己的电脑上、局域网服务器上,甚至是一台云主机上。Unity 客户端只是通过网络连过去的一个“外部系统”。
我的建议是,至少把项目划分成三层:
- Unity 客户端:负责玩家输入、表现渲染、本地 UI,以及把用户操作转换为网络消息发送。
- C# Socket 服务端:负责监听端口、接受连接、维护每个客户端会话、处理消息并广播给房间里的其他人。
- 协议层:客户端和服务端共同依赖的“通信约定”,比如消息格式、消息类型定义、序列化方式。
把这三层先分清楚,项目才不会在写到一半的时候变成一团乱麻。分层的另一个好处是,面试时你可以指着屏幕上的项目结构说,“服务端不依赖 Unity 的 API,把它单独拆开,是因为它本质上是一个网络服务,可以独立部署和测试。”
2. 动手第一步:先定协议,再写 Socket 代码
2.1 消息结构:长度 + 类型 + 内容
很多人写 Socket 项目,上来就打开 TcpListener 开始收发字符串,结果运行五分钟就发现消息全乱了。问题的根源,是没有先想清楚 TCP 传输的到底是什么。
TCP 是字节流协议,它本身不关心你发送的是“一条消息”“一句话”还是“一个 JSON”。当两个客户端连续发送多个消息时,接收方可能一次读到一个半消息,可能一次读到三个消息,也可能先读到半个消息。因此,我们必须自己定义消息边界。
一个最常用的结构是:消息头包含长度,消息体包含类型和内容。
// 消息结构示例: // 4字节消息长度 + 1字节消息类型 + N字节消息内容 public class NetMessage { public byte MessageType; public byte[] Content; }具体在设计时,常见的格式是:
| 字段 | 大小 | 说明 |
|---|---|---|
| 消息长度 | 4 字节 | 从类型开始到内容结束的总字节数 |
| 消息类型 | 1 字节 | 区分 EnterRoom、Move、PlayerState、PlayerLeave 等 |
| 消息内容 | 可变 | 根据类型反序列化成具体结构体 |
这个格式看起来简单,但它解决了两个核心问题:一是让接收方知道“这条消息什么时候才算完整”,二是为后续扩展提供稳定的消息分类入口。
实际编码时,类型字段最好定义成枚举或常量表,不要在消息体里直接用裸字符串表示“我是移动”。不然服务端每收到一条消息都得做字符串比较,既慢又容易拼错。
2.2 连接、心跳、重连:把“网络不可靠”当默认前提
TCP 协议本身可靠,不代表一条网络连接永远可靠。服务器断电、客户端断网、WiFi 切换、路由器重启,都会让连接在应用层没有任何感知的情况下失效。
所以从第一天起,就要在项目里引入心跳机制。最简单的心跳设计是:
- 客户端每 3 到 5 秒发送一个 Heartbeat 消息。
- 服务端记录每个客户端最后一次收到消息的时间。
- 如果超过设定时间(比如 15 到 30 秒)没有收到任何消息,服务端判定该客户端失活,主动清理连接和房间状态。
心跳和应用层的 keepalive 不是一回事。操作系统底层的 TCP KeepAlive 虽然也有探测机制,但默认探测周期很长,而且很多参数在云环境和容器环境里不容易配置。应用层自己做心跳,逻辑直观、可控性强,也比较容易在面试里讲清楚。
建议把心跳间隔、超时阈值这些参数做成常量而不是散落在代码各个角落。这样后续调整时只需要改一个地方。
2.3 粘包拆包:网络编程里最容易被问住的坑
“粘包拆包”几乎是面试网络编程时绕不开的问题。它的本质是:TCP 是字节流,不是消息流;你在应用层调用两次 Send,接收方不能假设一次 Receive 刚好对应一个 Send。
处理方式有很多,最常用的是“长度前缀法”。
接收方的逻辑应该是:
- 先把收到的数据写入一个缓冲区。
- 检查缓冲区中是否已经积累到至少 4 字节。
- 如果不够,继续等待下一次数据。
- 如果够,读出长度字段,再检查缓冲区是否包含完整的一条消息体。
- 如果还没够,继续积累;如果够了,从缓冲区中取出整条消息,交给消息处理器。
- 缓冲区剩余的部分,继续作为下一条消息的起始数据。
这一步是整个项目的含金量所在。很多网课只讲 Socket 的基本收发,但不会展开“如何在半小时内处理并发消息的粘连和半包”。你只要把这一块真正写清楚,面试时就已经能和只会调库的同学拉开差距。
如果觉得手动写拆包容易出错,也可以引入专业的序列化方案,或者基于现有通信框架重写,但实习项目我更建议自己写一轮。原因很简单:面试官真正关心的不是你有没有引入某个高大上的库,而是你知不知道 TCP 字节流背后的问题。
2.4 协议版本和兼容:提前留一条后路
实习项目不需要把协议做得很复杂,但有一个小习惯很值得从早期就保留:在协议结构里加一个版本号或协议标识字段。
客户端和服务端连上之后,可以先做一次握手,互相确认支持的最低协议版本。如果客户端版本和服务端版本不匹配,可以返回一个“版本过旧,请更新客户端”这样的消息,而不是等到消息收发到一半时才报错。
说实话,在一个几十人规模的小房间里,协议版本不兼容的问题可能并不会出现。但这个设计体现的是工程意识:你考虑到了未来会有多个客户端版本同时在线的场景。这个点讲出来,比多写一个游戏功能更能让面试官记住。
3. 服务端怎么设计:从 Accept 一个客户端到管理整个房间
3.1 最小服务端骨架:监听、接受连接、处理消息
C# 里写 TCP 服务端,最常见的 API 是 TcpListener、TcpClient、NetworkStream 这一套。最小骨架大概是这样的:
// 常见写法:监听端口后,为每个客户端创建一个异步处理任务 var listener = new TcpListener(IPAddress.Any, port); listener.Start(); while (true) { TcpClient tcpClient = await listener.AcceptTcpClientAsync(); _ = HandleClientAsync(tcpClient); }这里的 HandleClientAsync 会进入这个连接的接收循环:不断从 NetworkStream 读取数据,放入拆包缓冲,解析出完整消息,然后根据消息类型交给对应的处理器。
需要注意的是,不要给每个连接无脑开一个裸线程。在 C# 中,用Task或async/await模型通常更合适。因为大多数连接的常态是空闲等待,异步模型可以在等待数据时释放线程资源,而阻塞式线程模型在连接数一多之后,会变得非常沉重。
一个比较实用的抽象是引入 Session 字典:
- 用一个连接 ID 或 Session ID 标识每个客户端。
- 字典里保存该连接对应的 TcpClient、NetworkStream、客户端状态等。
- 当连接断开时,从字典移除并广播离开消息。
服务端只要维护好这个 Session 容器,房间、玩家、消息广播就都有了基础。
3.2 房间和玩家管理:状态从客户端到服务端的流转
合作生存游戏通常要有“房间”的概念。房间里可能有 2 到 4 个玩家,玩家可以一起创建房间、加入房间、退出房间,房间满员后其他人不能再进。
服务端因此至少需要两类状态:
- 玩家状态:玩家 ID、名称、位置、血量、当前房间 ID。
- 房间状态:房间 ID、房间内玩家列表、房间状态(等待中 / 游戏中 / 已结束)。
当客户端发来 EnterRoom 消息时,服务端要做的不只是“把玩家加入列表”,还包括:
- 检查目标房间是否存在。
- 检查房间是否满员。
- 生成或复用玩家实体信息。
- 把“该玩家已加入”的消息广播给房间里其他人。
- 把房间里现有的玩家快照发给新加入者,避免新玩家看到一个空房间。
这一步做好之后,你会发现自己已经在写一个非常轻量级的“游戏服务端”了。它不再只是收发消息,而是真正在管理游戏状态。
3.3 状态同步:先做广播,再谈优化
合作生存游戏最常见的同步需求是移动和战斗状态。最简单可用的做法是状态同步:
- 客户端定期把自己的位置、方向、动作状态发给服务端,例如每秒 10 到 15 次。
- 服务端收到后,以较短的间隔广播给同一房间的其他客户端。
- 其他客户端收到后,更新对应玩家的表现。
在一个小规模实习项目里,这种方案完全够用。不要去羡慕大型游戏的插值、预测、回滚等复杂方案——那些是在网络抖动和极低带宽条件下才必须引入的优化。你要做的是先把“一个玩家位置变化,另一个玩家能看得见”这条链路跑通。
广播时的性能问题要提前留意。最粗暴的方式是:每收到一条移动消息,就向房间内所有人转发一条。当玩家增多后,小包数量会急剧膨胀。一个很有效的优化是“房间内消息合并”:服务端不是每收到一条移动消息就立刻广播,而是固定间隔(比如每秒 10 次)把房间内所有玩家的状态打包成一条大消息广播一次。
这样做的优点是显著减少小包数量,缺点是客户端看到的位置会稍微滞后,但只要频率合理,延迟不会对合作生存类玩法造成明显影响。
3.4 服务端权威 vs 客户端权威:实习项目怎么选
这一节并不是劝你一开始就做服务端权威,而是要能讲清楚两者的差别。
最简单的方案是客户端权威:客户端说“我在这个坐标”,服务端就相信并广播给其他人。优点是实现快,逻辑都在客户端,服务端只负责转发。缺点是玩家可以轻易作弊,而且不同客户端之间的状态冲突很难处理。
服务端权威则是另一种思路:客户端只发送“操作意图”,比如“我想向左移动”“我想攻击”,服务端负责计算实际坐标、检查是否允许,然后广播最终结果。这样做安全性更好,状态也更一致,但开发量明显增加,服务端要承担更多游戏逻辑。
我的建议是:如果你只有一到两周时间准备这个项目,完全可以先做客户端权威,把网络链路和同步逻辑跑通;等核心流程稳定之后,再选一个小模块改成服务端权威,比如只让服务端校验血量变化或者钥匙开关。这样做既能控制开发规模,又能向面试官展示你理解两者差异,并且知道该在哪个环节引入权威校验。
4. Unity 客户端接入网络:不要让网络线程碰主线程
4.1 Socket 线程模型:为什么不能在 Update 里直接 Receive
Unity 的主循环是单线程的,所有 Transform、渲染、UI 操作都必须在主线程执行。而 Socket 接收数据是持续性的操作,如果把它放在 Update 里每帧检测,会有几个问题:
- 一帧内可能没有新数据,检测是空转。
- 如果数据量大,读取逻辑会阻塞主线程,导致掉帧。
- 如果一帧里消息特别多,还会造成明显的卡顿尖刺。
更合理的方式是让 Socket 接收跑在一个独立的网络线程或异步任务里,收到消息后先放到一个线程安全的队列,再由 Unity 主线程逐帧取出并处理。
这里要先有一个明确认知:网络线程负责“收”,主线程负责“用”,二者不能混在一起。
同时,不要在 Socket 线程里直接调用 Debug.Log 或修改 GameObject。Unity 的很多 API 并不是线程安全的,表面上偶发正常,一旦运行环境变化,就会出现莫名其妙的崩溃或状态错乱。
4.2 消息队列:跨线程传递的经典方案
C# 里处理跨线程队列,最简单的方式是使用ConcurrentQueue<T>。
// 常见写法:网络线程写入,Unity主线程消费 private ConcurrentQueue<Action> mainThreadActions = new ConcurrentQueue<Action>(); void Update() { while (mainThreadActions.TryDequeue(out Action action)) { action?.Invoke(); } }网络线程收到消息并解析后,不要在收包函数里直接处理游戏逻辑,而是把处理动作封装成一个 Action 塞进队列。比如:
// 收到其他玩家位置更新 mainThreadActions.Enqueue(() => { remotePlayer.transform.position = targetPosition; });这种模式的本质是:把“网络数据的产生”和“游戏逻辑的使用”解耦。网络再快再乱,主线程每帧只消费队列里已有的东西;网络再慢,主线程也不会因为没有新消息而被阻塞。
这个方法并不复杂,但几乎所有 Unity 网络项目都会用到。把它写熟练,后续不管接入什么网络库,核心思路都不会变。
4.3 表现层同步:玩家移动、血量、掉落物
当网络消息进入主线程后,你会发现真正花时间的并不是接收消息,而是“如何让表现足够自然”。
一个玩家收到另一个玩家的位置消息,如果把 Transform 直接设置成目标点,会因为消息间隔产生肉眼可见的跳变。因此通常要做简单插值:
// 示例思路:在两个状态点之间做插值,而不是直接跳变 transform.position = Vector3.Lerp(transform.position, targetPosition, smoothFactor);插值不是必须的,但它体现了一个重要意识:网络同步不能只追求“数据准确”,还要照顾玩家的视觉体验。
战斗和血量同步也是一样的思路。收到“BOSS 血量掉到 60%”的消息后,血条动画可以做渐变,而不是瞬间从 100% 跳到 60%。合作生存类游戏对打击感和反馈要求不低,表现层花一点时间做平滑,会让整个项目看起来完成度提高很多。
4.4 断线和重连:把崩溃变成可恢复
第一次跑联机 Demo 时,很多人会碰到一个情况:把 Unity 编辑器直接停止,或者把 WiFi 断开,再回来时客户端就卡死了。原因是没有处理连接断开事件。
客户端最好维护一个连接状态机:
- Connected:正常连接状态。
- Reconnecting:检测到断开,正在尝试重新连接。
- Disconnected:无法恢复,最终进入失败状态。
当 Socket 抛出连接异常,或者连续心跳超时,客户端不能只是弹一个提示框就结束,而是应该先关闭旧连接,清理本地输入队列,然后尝试重新连接服务端。如果重连成功,需要重新发送进入房间的协议,并且请求一份当前房间的状态快照;如果重连失败,再进入错误处理流程。
服务端这一侧也要做好配合:当检测到客户端连接断开时,及时从 Session 字典和房间中移除该玩家,并广播“有人离开”,避免其他客户端一直拖着一具“死连接”。
把这个状态机写出来,你的项目在应对异常时就不再是“听天由命”了。这个能力在真实工作场景中非常值钱。
5. 从跑通到面试复盘:这个项目要怎么讲才算讲透
5.1 面试官会问哪些问题,决定这个项目的分数上限
一个联机游戏项目,最容易被面试官追问的问题大概有这几类:
- 消息格式为什么这样设计?长度字段为什么是 4 字节?
- TCP 三次握手分别对应到了你 Socket 代码里的哪一步?
- 两个客户端同时进入同一个房间,服务端怎么保证新玩家看到的状态完整?
- 大量客户端同时移动,你的广播策略会带来什么性能问题?
- 如果服务端收到一条恶意或损坏的消息,整个进程会崩溃吗?
- 心跳超时了之后,客户端和服务端各自做了什么?
这些问题没有一个需要你背教科书答案。只要你真的把协议层、服务端、客户端的代码按自己的思路写了一遍,你自然就知道“Accept 是在第三次握手之后”“长度字段让我能在拆包层分辨半包”“服务端收到未知类型时要打日志然后丢弃而不是继续解析”。
相反,如果项目是照着某个教程敲的,没有理解每一行代码的动机,那么面试官只要追问一个“为什么”,问题就会暴露。
所以我的建议是,项目做完后不要急着投简历,先把这些问题写成一页纸,对着代码逐条回答一遍。答不出来的地方,就是你需要补代码或者补知识的地方。
5.2 五分钟 Demo 演示路径:先服务端,再客户端
面试或实习考核时,演示项目也要讲究顺序。不要一上来就打开 Unity 点 Play,那样面试官只能看到一个游戏画面,无法理解架构。
更建议的路径是:
- 先打开服务端控制台,让面试官看到端口监听、日志输出。
- 打开第一个 Unity 客户端,演示进入房间,此时服务端打出连接日志。
- 打开第二个 Unity 客户端,进入同一个房间,服务端广播“新玩家加入”。
- 操作两个窗口里的角色,演示位置同步、血量变化、一个玩家离开的效果。
- 最后拔掉网络或直接断开一个客户端,展示服务端经过心跳超时后,把掉线者踢出房间。
这套演示路径的节奏是:先证明网络模块存在,再证明它能处理连接和状态,最后证明它能应对异常。比单纯秀玩法更容易体现工程能力。
5.3 踩坑清单:实用排查顺序
实际开发中你会遇到很多报错,我把最常见的几类整理成一个排查顺序,供参考。
| 现象 | 优先排查方向 |
|---|---|
| 服务端启动报 bind 错误 | 端口是否被占用,换一个端口,或等待旧进程释放 |
| 客户端连接不上 | 先确认服务端 IP、端口、服务端是否启动,再检查防火墙 |
| 连接成功但消息错乱 | 重点检查拆包逻辑、长度字段、序列化方式是否一致 |
| 位置同步出现瞬移 | 检查客户端发送频率、服务端广播频率、插值是否生效 |
| 客户端运行一段时间后卡顿 | 检查是否在 Update 里做网络阻塞、日志是否高频输出、GC 分配是否过多 |
| 不同客户端看到的房间状态不一致 | 检查新玩家加入时是否拉取了房间快照,离开时是否广播了移除消息 |
排查时不要跳步。先看现象最具体的那一层,比如协议格式,再看环境因素,比如防火墙和端口,最后才怀疑框架问题。日志是关键,建议在服务端每个重要节点都打印一行:收到连接、进入房间、收到移动消息、广播状态、心跳超时、连接关闭。这些日志在调试和面试演示时都非常有用。
5.4 下一步优化的方向,以及什么时候该停下来
实习项目不是商业项目,不需要把所有功能都做满。与其铺开十个半成品功能,不如把一两个点做深。
几个值得投入的优化方向:
- 把广播发送从每 Player 单独广播改成按房间合并广播,测试一下客户端数量增多后的表现。
- 为服务端增加简单的日志和异常捕获,统计每个房间的连接数、消息数。
- 把客户端重连状态机完善,加入指数退避重试逻辑。
- 把协议内容从自定义二进制换成一个更通用的序列化方案,对比收包性能。
但也要知道什么时候该停下来。如果一个功能已经能够稳定演示、代码结构也清楚,就可以开始写项目文档和面试稿了。很多时候,继续加功能并不是真正的进步,反而是把原有架构搅乱,导致面试时讲不清楚。
我的判断是:这个项目做到能双人联机、稳定同步、断线可恢复、代码分模块,就已经超出很多应届实习生的平均水平。剩下更重要的事情,是把你的思考过程沉淀成可以表达的经验。
写在最后的个人经验
前几天有个学弟问我,做这种联机游戏,是不是真的能帮自己找到实习。我的回答是:项目本身不会直接换来 Offer,但它能让你在被问到“你做过什么”的时候有底气。
因为你真正把一个网络服务从零写了一遍。你见过连接建立之后第一次收到消息时的兴奋,你见过粘包拆包没写对导致的乱码,你见过心跳超时后服务端把僵尸连接清理干净的过程。这些经验不是背课本能得来的,它们是代码和运行日志给你的反馈。
对于 27 届的同学来说,现在这个阶段,比简历上多写一个花哨技能更重要的,是有一个能够经得起追问的硬核项目。TCP 多人联机合作生存游戏,恰恰就是这样一块很好的试金石。做它的价值,不在于让它看起来多接近商业游戏,而在于它让你对“程序之间如何通信”这件事,有了真正属于自己的体感。带着这种体感去面试,很多网络问题就不再是背诵题了。