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

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

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

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

相关推荐
遇印记3 小时前
传输层,网络层
运维·服务器·网络·windows·学习
北京中科新远科技4 小时前
高速光模块上线验收实战:DOM、FEC、BER与端口计数器怎么一起看
服务器·网络·php
80s7776 小时前
动态IP是什么,动态IP的应用场景有哪些?
网络·网络协议·tcp/ip
灯澜忆梦8 小时前
【Docker】#5 | 网络配置
网络·docker·容器
xixiaoyunya9 小时前
监控网页插件异常:用 HTTP 探针做第三方依赖的“外视”监测
网络·网络协议·http
是真的小外套9 小时前
第14章:恶意代码防范技术原理与应用
网络·网络安全
虎头金猫9 小时前
从上传PPT到观众远程打开互动页面:Claper在CentOS 7上的Docker部署、互动组件设置与固定访问地址实战
linux·运维·网络·人工智能·docker·centos·powerpoint
weixin_5118404710 小时前
2026年10月14日 Cisco and Nvidia 联合 USFP 调研报告
网络·人工智能·市场分析
AI备忘录10 小时前
(二十五)华为华三锐捷迈普思科 ERPS 环网保护配置命令(G.8032 五厂商对照)
运维·服务器·网络·网络协议·华为