游戏输入系统框架设计(三):网络输入权威与可验证性
联网输入的核心边界不是把本地事件原样转发,而是明确客户端时间、消息顺序和服务器模拟时间各自能证明什么,再由服务器决定输入意图是否进入权威状态。本篇集中讨论这一边界及其验证方法。
6. 网络输入的时间与权威
网络输入处理链中有四个不同的时间或顺序字段,分别由不同主体产生,不能互相替代:
| 字段 | 产生者 | 保存/使用位置 | 能做什么 | 不能做什么 |
|---|---|---|---|---|
| 客户端触发时间 | 客户端输入采集层 | 客户端预测、同一客户端辅助排序和诊断 | 解释本地输入节奏 | 决定跨客户端公平顺序或服务器模拟时间 |
| 客户端序号 | 客户端消息流 | 客户端发送记录、服务器连接状态 | 去重、检测缺失、确认处理进度和重放 | 代表服务器接收先后 |
| 服务器接收序号 | 服务器接收器 | 服务器消息队列和日志 | 提供同 Tick 内稳定的到达顺序 | 代替模拟时间或还原客户端按键时刻 |
| 服务器 Tick | 服务器模拟时钟 | 输入队列、状态机、战斗模拟 | 定义窗口、超时、消费和回放时间 | 还原客户端真实按键时刻 |
客户端输入采集层产生的本地时间没有跨机器的可比性,服务器接收器产生的序号也只表示消息到达顺序,不表示客户端实际按键时刻。对于同一 Tick 内不可交换的多个输入意图,服务器模拟应使用服务器 Tick、服务器接收序号和稳定 Tie-break,而不是比较不同客户端的本地时间。12
消息的保存策略取决于消息语义。按下、抬起和取消是边沿事件,丢失其中一个会改变输入周期,因此应进入可去重的队列;持续移动和瞄准是状态,旧值被新值覆盖通常没有意义,可以按输入主体保留最新值。可靠有序传输可以减少同一连接的乱序,但应用层序号仍然有助于去重、确认、异常检测和回放。3
组合输入识别责任的三种分配方式
| 方式 | 状态由谁保存 | 最终结果由谁产生 | 成本和风险 |
|---|---|---|---|
| 客户端识别后提交结果 | 客户端识别状态 | 客户端消息中的结果 | 消息少、响应快;服务器难以验证,作弊和状态分歧风险高 |
| 服务器接收原始输入并识别 | 服务器识别状态 | 服务器识别结果 | 权威且规则统一;消息量、服务器负载和响应延迟更高 |
| 客户端预测,服务器重新识别/校验 | 客户端保存预测状态,服务器保存权威状态 | 服务器识别结果 | 客户端反馈快且保留权威裁决;需要处理预测偏差、校正和状态重演 |
对于需要本地即时反馈、同时又要求服务器控制最终结果的联网游戏,推荐第三种分配方式。 客户端可以提前显示动作和做本地预测,但服务器重新识别组合输入并裁决最终业务结果。客户端提交的 ruleId 可以作为提示或优化字段,不能直接成为权威结果。客户端预测与服务器校正的关系可参考 Gambetta 的推导。4
Simulation Tick Queue Server Receiver Transport Client Simulation Tick Queue Server Receiver Transport Client #mermaid-svg-i8jQfBX42T5lxf6O{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-i8jQfBX42T5lxf6O .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-i8jQfBX42T5lxf6O .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-i8jQfBX42T5lxf6O .error-icon{fill:#552222;}#mermaid-svg-i8jQfBX42T5lxf6O .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-i8jQfBX42T5lxf6O .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-i8jQfBX42T5lxf6O .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-i8jQfBX42T5lxf6O .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-i8jQfBX42T5lxf6O .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-i8jQfBX42T5lxf6O .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-i8jQfBX42T5lxf6O .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-i8jQfBX42T5lxf6O .marker{fill:#333333;stroke:#333333;}#mermaid-svg-i8jQfBX42T5lxf6O .marker.cross{stroke:#333333;}#mermaid-svg-i8jQfBX42T5lxf6O svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-i8jQfBX42T5lxf6O p{margin:0;}#mermaid-svg-i8jQfBX42T5lxf6O .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-i8jQfBX42T5lxf6O text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-i8jQfBX42T5lxf6O .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-i8jQfBX42T5lxf6O .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-i8jQfBX42T5lxf6O .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-i8jQfBX42T5lxf6O .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-i8jQfBX42T5lxf6O #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-i8jQfBX42T5lxf6O .sequenceNumber{fill:white;}#mermaid-svg-i8jQfBX42T5lxf6O #sequencenumber{fill:#333;}#mermaid-svg-i8jQfBX42T5lxf6O #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-i8jQfBX42T5lxf6O .messageText{fill:#333;stroke:none;}#mermaid-svg-i8jQfBX42T5lxf6O .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-i8jQfBX42T5lxf6O .labelText,#mermaid-svg-i8jQfBX42T5lxf6O .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-i8jQfBX42T5lxf6O .loopText,#mermaid-svg-i8jQfBX42T5lxf6O .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-i8jQfBX42T5lxf6O .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-i8jQfBX42T5lxf6O .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-i8jQfBX42T5lxf6O .noteText,#mermaid-svg-i8jQfBX42T5lxf6O .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-i8jQfBX42T5lxf6O .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-i8jQfBX42T5lxf6O .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-i8jQfBX42T5lxf6O .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-i8jQfBX42T5lxf6O .actorPopupMenu{position:absolute;}#mermaid-svg-i8jQfBX42T5lxf6O .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-i8jQfBX42T5lxf6O .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-i8jQfBX42T5lxf6O .actor-man circle,#mermaid-svg-i8jQfBX42T5lxf6O line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-i8jQfBX42T5lxf6O :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} clientTime用于本地预测和诊断 serverSequence用于同Tick稳定排序 Ack确认处理进度,不接受客户端战斗结果 InputIntent(clientSequence, clientTime)Deliver messageassign serverSequenceenqueue with targetTickinputs for targetTickvalidate, recognize, apply idempotentlyAck(lastProcessedClientSequence)
服务器侧处理链必须显式产生服务器序号和目标 Tick,并在模拟边界消费:
csharp
void OnReceive(InputMessage message, ConnectionStream stream)
{
// clientSequence 由一个客户端的消息流产生;服务器只使用该序号去重和确认进度。
int clientSequence = message.ClientSequence;
if (stream.HasSeen(clientSequence))
{
diagnostics.Record(message, "DuplicateClientSequence");
SendAck(stream, stream.HighestContiguousProcessed);
return;
}
if (!validator.AcceptEnvelope(message))
{
diagnostics.Record(message, "InvalidEnvelope");
SendAck(stream, stream.HighestContiguousProcessed);
return;
}
// 以下两个字段只能由服务器产生:serverSequence 表示到达顺序,targetTick 表示模拟归属。
long serverSequence = receiveSequencer.Next();
Tick targetTick = tickScheduler.Assign(serverClock.CurrentTick, message);
if (targetTick < simulation.CurrentTick - latePolicy.MaxAcceptedLateness)
{
diagnostics.Record(message, "TooLate");
stream.MarkSeen(clientSequence);
SendAck(stream, stream.HighestContiguousProcessed);
return;
}
stream.MarkSeen(clientSequence);
inputQueue.Enqueue(new QueuedInput(
message, stream.UserId, clientSequence, serverSequence, targetTick));
}
void ProcessTick(Tick tick)
{
List<QueuedInput> items = inputQueue.TakeFor(tick);
// 同一 Tick 内使用服务器顺序和稳定的用户 ID 排序,不能比较客户端时间。
items.SortBy(item => (item.ServerSequence, item.UserId));
foreach (QueuedInput item in items)
{
if (item.TargetTick < tick && latePolicy.Reject(item))
{
diagnostics.Record(item, "LateForSimulation");
ackTracker.MarkProcessed(item);
continue;
}
if (!validator.AcceptForSimulation(item))
{
diagnostics.Record(item, "RejectedByServer");
ackTracker.MarkProcessed(item);
continue;
}
// ApplyInputIntent 必须按客户端序号或消息 ID 幂等执行,重发不能产生第二次效果。
simulation.ApplyInputIntent(item);
ackTracker.MarkProcessed(item);
}
// Ack 只确认服务器已处理到哪里,不代表客户端提交的业务结果被接受。
SendAckFor(tick, ackTracker.HighestContiguousProcessed);
}
clientSequence 用于同一连接的去重、缺失检测和确认;serverSequence 由服务器产生,不能由客户端伪造;targetTick 由服务器根据接收边界分配。迟到消息要么被丢弃,要么进入明确的补偿路径,不能悄悄修改已经执行的模拟状态。丢包和重发需要配合序号与幂等处理;服务器卡顿需要限制追赶 Tick 的数量。若系统允许修改已经执行的过去 Tick,就进入 rollback 范围,必须保存状态快照、保证模拟确定性并重演输入;rollback 不是给普通输入队列增加一个缓存。25
7. 可验证性与架构检查
输入故障应沿着"原始信号 -> 语义映射 -> 上下文 -> Policy -> 订阅 -> 网络 -> 逻辑状态"定位。排查人员要在每一层记录实际观察到的字段和该层的决定,不能只记录最终业务有没有响应:
| 检查阶段 | 观察字段 | 常见原因 |
|---|---|---|
| 设备信号 | 原始值、采样时间、设备连接状态 | 设备断开、采样没有发生或死区过滤了有效输入 |
| 语义映射 | binding、动作 ID、payload 类型 | binding 无效、动作未注册或 payload 类型不匹配 |
| Map | 动作所属 Map、当前有效 Map 集合 | 输入上下文未启用、模态层覆盖或退出时未恢复 |
| Policy | decision、Owner、Priority、reason | 规则阻断、事件已消费、规则残留或优先级错误 |
| 订阅 | subscription Owner、注册时间、释放时间 | 重复订阅、对象销毁后仍回调或订阅提前释放 |
| 网络发送 | clientSequence、发送结果、重试次数 | 发送失败、重复发送或消息在发送前已过期 |
| 服务器接收 | serverSequence、targetTick、late 状态 | 消息迟到、重复、校验失败或被队列丢弃 |
| 业务消费 | 业务状态、接受条件、状态转移结果 | 业务状态拒绝指令、匹配结果未消费或执行顺序错误 |
测试应集中在边界,而不是重复覆盖每个物理按键:阶段重复、丢失释放、取消和上下文切换;多个 Owner、Priority、Layer 的覆盖和清理;Consume 与普通事件传播的边界;改键冲突、取消回滚和配置迁移;摇杆方向边界、轨迹超时和多规则竞争;网络重复、乱序、迟到、丢包、确认和跨客户端竞争;固定输入序列的记录、重放和结果一致性。
参考资料
1 Fiedler, Glenn. What Every Programmer Needs To Know About Game Networking EB/OL. Gaffer On Games, 2010-02-24. https://gafferongames.com/post/what_every_programmer_needs_to_know_about_game_networking/ (访问日期:2026-08-31)。
2 Fiedler, Glenn. Deterministic Lockstep EB/OL. Gaffer On Games, 2014-11-29. https://gafferongames.com/post/deterministic_lockstep/ (访问日期:2026-08-31)。
3 Fiedler, Glenn. Reliability, Ordering and Congestion Avoidance over UDP EB/OL. Gaffer On Games, 2008-10-20. https://gafferongames.com/post/reliability_ordering_and_congestion_avoidance_over_udp/ (访问日期:2026-08-31)。
4 Gambetta, Gabriel. Client-Side Prediction and Server Reconciliation EB/OL. Gabriel Gambetta. https://www.gabrielgambetta.com/client-side-prediction-server-reconciliation.html (访问日期:2026-08-31)。
5 GGPO. Rollback Networking SDK for Peer-to-Peer Games EB/OL. GGPO. https://www.ggpo.net/ (访问日期:2026-08-31)。