游戏输入系统框架设计(三):网络输入权威与可验证性

游戏输入系统框架设计(三):网络输入权威与可验证性

联网输入的核心边界不是把本地事件原样转发,而是明确客户端时间、消息顺序和服务器模拟时间各自能证明什么,再由服务器决定输入意图是否进入权威状态。本篇集中讨论这一边界及其验证方法。

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)。

相关推荐
Mortalbreeze1 小时前
深入理解 Linux IO 模型(二):多路复用——select
linux·运维·服务器·网络·tcp/ip
SamChan901 小时前
PDF翻译服务端到端基准测试:4款主流方案的吞吐量、延迟、内存占用对比
服务器·网络·python·ai·pdf·wpf
Sagittarius_A*1 小时前
CVE-2026-42271:从 MCP stdio 测试接口看 LiteLLM 的命令执行风险与防御闭环
网络·windows·安全·cve·rce
DLYSB_1 小时前
旧上位机不肯改怎么办:原生 TCP 字节帧接入声光语音终端的改造实录
网络·网络协议·tcp/ip·报警灯
ACP广源盛139246256731 小时前
M6/M5 Pro Mac mini 端侧 AI 爆发@ACP#YLB3118 存储扩展芯片在本地 AI 服务中的机会与落地场景
大数据·网络·数据库·人工智能·嵌入式硬件·macos
程序员夏洛1 小时前
HTTP 2.0 和 3.0 有什么区别?
网络·网络协议·http
丶浅行DE时光2 小时前
管道式电磁流量计选型指南 介质腐蚀与工况适配方案推荐
大数据·网络·人工智能·科技·推荐算法
谈资一本本2 小时前
电脑全能修复大师 Gilisoft Total Repair,智能检测修复系统、应用、游戏问题!DLL、DirectX、.NET运行库损坏修复+缺失自动补齐!
游戏·电脑·.net
段一凡-华北理工大学2 小时前
高炉智能布料技术与炉料分布优化~系列文章03:布料矩阵解码:环位、角度、圈数与料流的量化控制
java·网络·人工智能·矩阵·高炉智能化·高炉布料矩阵优化·高炉智能布料技术