MassBattle Docsv1.19.20

网络同步使用指南 Network Sync Guide

交互式演示 Interactive Demo 使用指南 Usage Guide

依据:Source/MassBattle/Public、Private/{Network, Subsystems, Processors} 网络同步源码 · 演示地图 GameMap_HordeSurvival_Replicated(BP_NetworkGameMode / BP_NetworkGameState / BP_Player)Basis: network-sync sources under Source/MassBattle/{Public,Private}/{Network, Subsystems, Processors} · demo map GameMap_HordeSurvival_Replicated (BP_NetworkGameMode / BP_NetworkGameState / BP_Player)

概述 Overview

MassBattle 的网络同步采用确定性锁步(deterministic lockstep)模型:网络上只传输玩家输入命令和每 tick 的状态哈希,数千甚至上万个单位的位置、血量、行为完全不逐个同步。每台机器(服务器与全部客户端)运行完全相同的模拟代码、以完全相同的顺序吃进相同的命令,从而各自推演出逐位一致(bit-identical)的世界。哈希不一致(desync,失步)时才动用快照(snapshot)重建。带宽只随玩家数增长,与实体数无关。

MassBattle networking uses a deterministic lockstep model: only player input commands and a per-tick state hash travel the wire — positions, health and behavior of thousands of agents are never replicated individually. Every machine (server and all clients) runs the exact same simulation code and consumes the exact same commands in the exact same order, so each derives a bit-identical world. Snapshots are only used when hashes diverge (desync). Bandwidth scales with player count, not entity count.

本页分三部分:① 原理(锁步是什么、K 缓冲、失步检测与恢复、快照系统),配一个可操作的交互式时间线演示;② 使用指南——照着 HordeSurvival 联机演示地图的三个蓝图,从零接入一套完整的联机玩法;③ 调试与硬规则——失步排查工具链与绝不能踩的雷。

This page has three parts: ① Principles (what lockstep is, the K-buffer, desync detection & recovery, the snapshot system) with a hands-on interactive timeline demo; ② Usage guide — wire up a complete networked game from zero, following the three blueprints of the HordeSurvival replicated demo map; ③ Debugging & hard rules — the desync forensics toolchain and the pitfalls you must never step on.

10K+ 联机 Agentnetworked agents

演示地图在联机模式下持续刷到 10000 上限,双端哈希保持一致。

The demo map spawns up to the 10,000-agent cap in networked play while peer hashes stay identical.

30 Hz 固定步长fixed step

模拟以 0.0333s 步长 tick 驱动;网络层节拍可经 NetworkFlushIntervalTicks 降频。

Simulation ticks at a 0.0333s fixed step; the network layer cadence can be decoupled via NetworkFlushIntervalTicks.

< 1s 失步恢复desync recovery

失步后优先本地快照恢复(不打扰服务器),兜底走服务器快照 + 追帧。

Desync recovers local-snapshot-first (server undisturbed), falling back to a server snapshot + fast-forward.

16 KB 自动分流阈值auto-flood threshold

超过阈值的命令自动改走 TCP 数据块 + 轻量 UDP 引用命令,绕开 RPC 上限。

Commands past the threshold auto-switch to a TCP data block + a lightweight UDP reference command, bypassing RPC limits.

什么是确定性锁步 What is Deterministic Lockstep?

下棋类比:两个在不同城市的棋手不下发棋盘照片,只宣布走法。相同的初始棋盘 + 相同的规则 + 相同的走法顺序 ⇒ 棋盘永远一致——根本不需要检查对方的棋盘。MassBattle 把整个战场当作这盘棋:走法就是命令(Command),规则就是确定性模拟。

Chess analogy: two players in different cities exchange moves, not board photos. Identical boards + identical rules + identical move order ⇒ always-identical boards — without ever checking each other's board. MassBattle treats the whole battlefield as that game: moves are Commands, rules are the deterministic simulation.

支柱一:确定性模拟Pillar 1: Deterministic simulation

相同输入 ⇒ 逐位相同的输出。固定步长 tick、以 UID 为种子的逐实体随机流、排序后串行刷新的并行收集、哈希折叠时对 -0.0 / 反规格化浮点的规范化——这套纪律贯穿全部模拟代码。

Same input ⇒ bit-identical output. Fixed-step ticks, per-entity random streams seeded from the UID, parallel-gather-then-sorted-flush, and hash-fold canonicalization of -0.0 / subnormal floats — the discipline runs through all simulation code.

支柱二:一致的命令顺序Pillar 2: Identical command order

所有端以 (ExecuteTick, ServerExecSequence) 的唯一全序执行命令:ExecuteTick 定 tick,ServerExecSequence 是服务器为同一 tick 内的多条命令分配的权威序号。

Every peer executes commands in the single total order (ExecuteTick, ServerExecSequence): ExecuteTick picks the tick; ServerExecSequence is the server-assigned authority order among commands sharing a tick.

支柱三:哈希校验Pillar 3: Hash verification

每帧把全部网络实体的关键字段折叠成逐实体 CityHash64,再合并成全局 tick 哈希;服务器广播、客户端比对,失配即失步。锁步不保证一致——它检测不一致。

Every frame folds key fields of all networked entities into per-entity CityHash64 values, combined into a global tick hash; the server broadcasts it, clients compare, mismatch = desync. Lockstep doesn't guarantee agreement — it detects divergence.

为什么选择锁步 Why Lockstep?

服务器权威状态复制(FPS 常用)Server-auth state replication (typical FPS)

服务器下发所有实体的位置:5000 实体 × 30Hz ≈ 每客户端 48 Mbps——万人战场直接不可行。

The server ships every entity's position: 5,000 entities × 30Hz ≈ 48 Mbps per client — hopeless for ten-thousand-strong battles.

客户端预测 + 回滚Client prediction + rollback

上万个实体各自回滚重演,CPU 与视觉(群体拉扯)双重灾难,实现复杂度也极高。

Rolling back and replaying tens of thousands of entities is a CPU and visual (mass rubber-banding) disaster, with brutal implementation complexity.

确定性锁步(MassBattle)Deterministic lockstep (MassBattle)

带宽 ∝ 玩家数而非实体数。5 万实体 = 零额外流量;代价是全部模拟代码必须确定性、且要维护失步检测与恢复。

Bandwidth ∝ player count, not entity count. 50K entities = zero extra traffic; the price is a fully deterministic simulation plus desync detection & recovery machinery.

架构总览 Architecture Overview

框架由四层组成。注意 RPC 的放置由引擎规则决定:AGameMode 只在服务器存在,因此下行多播挂在 GameState、上行 Server RPC 挂在 PlayerController——二者两端都有。命令本身是 FMassBattleNetCommand(tag + 四种负载数组 + 调度信息),执行入口统一是 AMassBattleNetworkGameStateBase::ExecuteNetCommand。

The framework has four layers. RPC placement follows engine rules: AGameMode exists only on the server, so downlink multicasts live on GameState and uplink Server RPCs on PlayerController — both exist on every machine. A command is an FMassBattleNetCommand (tag + four payload arrays + scheduling info); the single execution entry is AMassBattleNetworkGameStateBase::ExecuteNetCommand.

1
输入层(你的蓝图)Input layer (your Blueprints)
Enhanced Input / 定时器 / 服务器波次逻辑构造命令,经 SendLockstepCommandToServer(客户端上行)或 SendLockstepCommandToClients(服务器广播)送入管线。Enhanced Input / timers / server wave logic build commands and feed the pipeline via SendLockstepCommandToServer (client uplink) or SendLockstepCommandToClients (server broadcast).
2
调度层(服务器收口)Scheduling layer (server choke point)
ReceivePlayerCommand 三道闸门(客户端命令白名单、每玩家每 tick 配额、待广播队列上限)→ K 缓冲排程,分配 ExecuteTick = 基准 + K + ExtraDeferTicks 与 ServerExecSequence。ReceivePlayerCommand applies three gates (client command whitelist, per-player per-tick quota, pending-broadcast cap) → K-buffer scheduling assigns ExecuteTick = base + K + ExtraDeferTicks and ServerExecSequence.
3
传输层(双通道)Transport layer (dual channel)
小命令走引擎 UDP 可靠 RPC;≥ 16KB 自动切到自建 TCP 通道(快照与大负载块,游戏端口 +100 起,令牌认证、心跳、分块重组)。Small commands ride engine reliable UDP RPCs; ≥ 16KB auto-switches to the custom TCP channel (snapshots and large payload blocks — game port +100 upward, token auth, heartbeats, chunked reassembly).
4
执行层(全部端)Execution layer (every peer)
各端按 ExecuteTick 出队 → 内置 tag 分发 + 蓝图钩子 ReceiveLockstepCommandFromServer → 确定性 Mass 模拟 → UMassBattleNetworkProcessor 折叠哈希 + 周期采集快照。Each peer dequeues at ExecuteTick → built-in tag dispatch + the Blueprint hook ReceiveLockstepCommandFromServer → deterministic Mass simulation → UMassBattleNetworkProcessor folds hashes and periodically captures snapshots.

RPC 清单(节选,全部可在 GameStateBase / PlayerControllerBase API 页查到):

RPC roster (excerpt; full list on the GameStateBase / PlayerControllerBase API pages):

RPC宿主Host方向Direction用途Purpose
MulticastCommandToClientsGameState服→全员Svr→All广播锁步命令(可靠)Broadcast a lockstep command (reliable)
MulticastSyncTickGameState服→全员Svr→All服务器 tick + 输入缓冲 + 时间膨胀(客户端调速的唯一来源)Server tick + input buffer + time dilation (sole source for client pacing)
MulticastDesyncCheckGameState服→全员Svr→All广播本 tick 哈希供客户端比对Broadcast the tick hash for client-side comparison
SendLockstepCommandToServerPlayerController客→服Cli→Svr客户端提交命令(大负载自动走 TCP 上行块)Client submits a command (large payloads auto-ride the TCP uplink block)
ServerAckTickPlayerController客→服Cli→Svr心跳:ack 已模拟 tick + 本地哈希(服务器侧对称校验)Heartbeat: ack the simulated tick + local hash (server-side mirror check)
ServerRequestResync / ServerResyncCompletePlayerController客→服Cli→Svr请求 / 完成快照重同步Request / complete a snapshot resync
ClientBeginTcpSnapshotResyncPlayerController服→单人Svr→One通知客户端经 TCP 接收快照(带端口与令牌)Tell the client to receive a snapshot over TCP (port + token)
ClientFastForwardPlayerController服→单人Svr→One命令客户端不限步调追帧到目标 tickOrder an unpaced fast-forward to a target tick

交互式时间线 Interactive Timeline

下面的演示还原了锁步的完整节奏:客户端 0(上)把输入发给服务器(中),服务器排入 K 缓冲后广播给客户端 0 与客户端 1(下)。可以调大网络延迟或把客户端推到滞后,观察命令迟到(红色 LATE)→ 暂停 → 快照 → 追帧的完整恢复链——这正是 MassBattle 失步恢复的行为模型。

The demo below replays the full lockstep rhythm: Client 0 (top) sends input to the Server (middle), which schedules it through the K-buffer and broadcasts to Client 0 and Client 1 (bottom). Crank up the net delay or push a client behind to watch the complete recovery chain — late command (red LATE) → pause → snapshot → fast-forward — which is exactly how MassBattle desync recovery behaves.

客户端 0Client 0
服务器Server
客户端 1Client 1
输入Input
广播Broadcast
准时 / 快照On-time / Snapshot
迟到LATE
🔄 重同步Resync
| |

怎么玩:① 点「发送输入」,看黄色输入包上行、服务器排期、紫色广播包下行、两端在自己的 ExecuteTick 上点亮执行标记;② 把延迟调到 300ms 以上再发——广播到达时该 tick 已过,标记变红 LATE,落后端随即进入 🔄 暂停 → 快照 → 6 倍速追帧;③ 用「推到滞后 / 推到超前」直接制造偏差,观察调速带(faster / slower / PAUSED / FAST-FWD)——这就是客户端四档调速的直观版;④ 调小 K 再加大延迟,迟到更容易复现。

How to play: ① Click "Send Input" — watch the yellow input packet go up, the server schedule it, purple broadcasts come down, and both peers light the execute marker at their ExecuteTick. ② Raise the delay past 300ms and send again — the broadcast lands after the tick has passed, the marker turns red LATE, and the lagging peer enters 🔄 pause → snapshot → 6× fast-forward. ③ Use "Push Behind / Push Ahead" to create offset directly and watch the pacing bands (faster / slower / PAUSED / FAST-FWD) — an intuitive view of the four client pacing tiers. ④ Lower K and raise delay to make lateness easier to reproduce.

命令生命周期 Command Lifecycle

一条命令从玩家手指出发到全军一致移动的完整旅程(对照当前源码,以演示地图的 Player.DealDmg 为例):

The full journey of a command from a player's fingertip to an identically-moving army (per current source, using the demo's Player.DealDmg as the example):

  1. 玩家蓝图构造命令:0.75s 循环定时器触发 SendLockstepCommandToServer("Player.DealDmg", IntPayload=[AgentUID, 减益类型], FloatPayload=[X, Y, Z])。
  2. PlayerController 的 Server_SendNetCommand Server RPC 上行;服务器盖上真实 PlayerIndex 后进入 ReceivePlayerCommand。
  3. 三道闸门:命令白名单(服务器权威 tag 一律拒绝,其余必须在 ClientAllowedCommandTags 中)→ 每玩家每 tick 准入配额(默认 16)→ 待广播队列上限(4096)。
  4. K 缓冲排程:ExecuteTick = max(服务器tick, 发起tick, 最快客户端ack) + K + ExtraDeferTicks,并在该 tick 下分配递增的 ServerExecSequence;超过 16KB 的负载自动改走 TCP 数据块 + 引用命令。
  5. 排序后经 GameState 的 MulticastCommandToClients 可靠多播;服务器自己也入队(EnqueueNetCommand)。
  6. 各端按 (ExecuteTick, ServerExecSequence) 插入本地待执行队列;以 (PlayerIndex, ExecuteTick, TagHash, ServerExecSequence) 为键去重,重复投递不会二次执行。
  7. 模拟开始前主 Tick 出队到期的命令,交给 GameState::ExecuteNetCommand:内置 tag 走 C++ 分支,其余落到蓝图钩子 ReceiveLockstepCommandFromServer —— 服务器与所有客户端在同一个 ExecuteTick 收到它。
  8. 蓝图钩子里执行效果(本例:ApplyRadialDamageAndDebuff 在命令携带的位置放一个 750 半径的范围伤害圈)——各端输入相同、模拟相同,结果逐位一致。
  9. 帧末 NetworkProcessor 折叠哈希;服务器广播哈希,客户端比对,形成闭环校验。

迟到零容忍:任何 ExecuteTick < 当前tick 的命令一律跳过并立刻转入失步恢复——绝不补放到当前 tick。补放会与服务器在原 tick 的执行静默发散,制造出"无故失步"。在 K 缓冲 + TCP 块暂存冻结的秩序下,迟到在结构上不应发生;出现即传输层故障。

Zero tolerance for lateness: any command with ExecuteTick < currentTick is skipped and immediately routed into desync recovery — never replayed at the current tick. Deferred application silently diverges from the server's execution at the original tick (the "causeless desync" signature). Under the K-buffer + staged-TCP-block-freeze discipline lateness is structurally impossible; firing means a transport fault.

K 缓冲 The K-Buffer

K 缓冲是一段故意引入的输入延迟(以 tick 计):命令不立即执行,而是排到未来第 K 个 tick,给网络留出"所有人都能在执行前收到"的时间窗。K 由静态输入缓冲换算:K = ceil(MinInputBufferMs / 每tick毫秒),默认 150ms(30Hz 下 K=5)。广域网环境请在 GameMode 配置中调大 MinInputBufferMs;生成类命令另有 ExtraSpawnBufferMs(默认 66ms)加码。

The K-buffer is a deliberate input delay measured in ticks: commands execute K ticks in the future, leaving a window in which every peer can receive them before execution. K derives from a static input buffer: K = ceil(MinInputBufferMs / msPerTick), default 150ms (K=5 at 30Hz). For WAN play raise MinInputBufferMs in the GameMode config; spawn commands get an extra ExtraSpawnBufferMs (default 66ms).

Tick→Tick→
T0
T1
T2
T3
T4
T5
T6
T7
T8
玩家Player
W↓
网络Network
→→
→→
→→
K-Buffer
Buf
Buf
Buf
所有端All peers
Exec!

T1 按 W → T1–T3 网络传输 → K 缓冲保持 → T4 全端同时执行。W pressed at T1 → network transit T1–T3 → held in the K-buffer → all peers execute at T4.

K 缓冲 vs 传统 RTS 屏障同步 K-Buffer vs Traditional RTS Barrier Sync

根本区别:屏障同步里服务器必须等到所有客户端确认才推进下一 tick——一人卡顿、全体冻结(星际争霸 / 帝国时代的经典手感)。K 缓冲里服务器从不等待:命令提前排期到未来执行,跟不上的端自己失步、自己恢复。

The fundamental difference: under barrier sync the server waits for all clients to ack before advancing a tick — one slow peer freezes everyone (the classic StarCraft / AoE feel). Under the K-buffer the server never waits: commands are scheduled into the future; a peer that can't keep up desyncs and recovers alone.

屏障同步(星际 / 帝国时代)Barrier sync (StarCraft / AoE)

服务器ServerTick N ✓→等全部确认…Wait ALL…→N+1 ✓
客户端AClientA████████→等待…waiting…→████
客户端C(慢)ClientC (slow)██(慢)(slow)→ 被 C 一人拖死所有人!EVERYONE FROZEN by C!

K 缓冲(MassBattle)K-buffer (MassBattle)

服务器ServerN ✓→N+1 ✓→N+2 ✓从不等待NEVER waits
正常端Normal████████████流畅smooth
落后端Lagging████░░命令迟到→跳过→失步cmd LATE→skip→DESYNC→重同步resync
方面Aspect屏障(RTS)Barrier (RTS)K 缓冲(MassBattle)K-buffer (MassBattle)
等待模型Wait model服务器等全部确认Server waits for ALL acks服务器不等——提前调度Server never waits — schedules ahead
慢端影响Slow peer impact所有人冻结Everyone freezes只有落后端自己受损(反复 resync)Only the lagging peer suffers (repeated resyncs)
公平性Fairness完美——所有人体验一致Perfect — identical experience for all有损——高延迟玩家反复失步重同步;低配机器可能跟不上模拟帧率Degraded — high-latency players resync repeatedly; weak machines may trail the sim rate
策略Strategy"一个不能少"(集体受罪)"No one left behind" (collective pain)"牺牲少数保护多数""Sacrifice few to protect many"

诚实的代价清单:① 网络延迟——延迟超出 K(含其钳制上限,MaxInputBufferMs 默认 300ms)所能吸收的范围时,该玩家会陷入 失步→重同步 循环,无药可救;屏障同步会让所有人陪他降速,但没人被排除。② 硬件性能——跑不动目标模拟帧率的机器会持续累积 tick 债务直至失步;缓解思路是让服务器按最弱客户端动态降低全局模拟帧率(以全体精度换兼容性),而网络延迟造成的失步连这条路也没有。MassBattle 面向大规模高帧率战斗选择 K 缓冲:让多数人享受流畅模拟,而不是保证所有人体验一致。

An honest bill of costs: ① Network latency — a player whose ping exceeds what K (with its MaxInputBufferMs clamp, default 300ms) can absorb enters a desync→resync loop with no remedy; barrier sync would slow everyone down but exclude no one. ② Hardware — a machine that can't hold the target sim framerate accumulates tick debt until desync; a planned mitigation lets the server lower the global sim rate to match the weakest client (inclusivity at the cost of fidelity), but latency-induced desync has no such escape hatch. MassBattle picks the K-buffer for large-scale, high-rate combat: a crisp simulation for the majority rather than a guaranteed-identical experience for everyone.

K 是静态值而非动态值:当前实现直接取 MinInputBufferMs(LAN 导向,不做逐客户端 RTT/抖动测量)。配置项 MinInputBufferMs / MaxInputBufferMs(150 / 300ms)仍保留在 GameMode 上——广域网请直接调大 Min。另一个方向的自动调节是服务器时间膨胀:服务器跟不上实时时下调 TimeDilation,客户端同步降速保持对齐(MulticastSyncTick 携带)。

K is static, not dynamic: the current implementation returns MinInputBufferMs directly (LAN-oriented, no per-client RTT/jitter measurement). The MinInputBufferMs / MaxInputBufferMs (150 / 300ms) properties remain on the GameMode — raise Min for WAN. The other direction self-adjusts via server time dilation: when the server can't hold real-time it lowers TimeDilation and clients slow down in lockstep (carried by MulticastSyncTick).

失步检测与恢复 Desync Detection & Recovery

检测是双侧的。客户端侧:服务器每 flush tick 广播哈希(MulticastDesyncCheck),客户端在本 tick 模拟完成后于确定性 post-tick 边界比对。服务器侧:客户端每次 ack(ServerAckTick)携带本地哈希,服务器对账。两端都有 5 tick 防抖(DesyncConfirmTicks):瞬态毛刺不升级,真发散只是晚 5 tick 被发现——防抖层是保险,不是"毛刺可容忍"的许可。服务器确认失步后会隔离该客户端(丢弃其上行命令)直到恢复完成。

Detection is two-sided. Client side: the server broadcasts the hash every flush tick (MulticastDesyncCheck); the client compares at the deterministic post-tick boundary after finishing the tick. Server side: every client ack (ServerAckTick) carries the local hash for reconciliation. Both sides apply a 5-tick debounce (DesyncConfirmTicks): transient blips never escalate, real divergence is merely caught 5 ticks later — the debounce is insurance, NOT a license to tolerate blips. On confirmation the server quarantines the client (dropping its uplink commands) until recovery completes.

第一优先:本地快照恢复First: local snapshot recovery

客户端自己也每 90 tick 采集快照进环形缓冲(8 槽);用被服务器哈希验证过的最新一份回滚,从命令历史补 gap 命令,再追帧——全程不打扰服务器。

The client also captures snapshots every 90 ticks into an 8-slot ring; it rolls back to the newest server-validated one, re-fills gap commands from local history, then fast-forwards — the server is never disturbed.

兜底:服务器快照Fallback: server snapshot

本地不可用(或上次本地恢复未收敛)→ 暂停并请求服务器快照:TCP 全量传输 → 全部网络实体销毁重建 → 应用 blob → 恢复后重折叠哈希与 ServerHashAtCapture 比对验证保真 → 追帧。

Local unavailable (or the last local recovery didn't converge) → pause and request a server snapshot: full TCP transfer → destroy & rebuild all networked entities → apply blobs → re-fold and compare against ServerHashAtCapture to validate fidelity → fast-forward.

追帧中止Fast-forward abort

追帧途中掉队服务器前沿超过 600 tick(FastForwardAbortBehindTicks)即中止,改要一份新快照直接跳到前沿附近——倍速硬追数百 tick 要数分钟,不值得。

If a fast-forward falls > 600 ticks behind the live server head (FastForwardAbortBehindTicks) it aborts and requests a fresh snapshot that jumps near the head — hard-chasing hundreds of ticks at FF rate takes minutes.

客户端调速四档(客户端视角 ClientServerTickDelta = 本地tick − 服务器tick,负数为落后)与服务器踢出:

Four client pacing tiers (client view: ClientServerTickDelta = local − server, negative = behind) plus the server kick:

档位Tier条件Condition动作Action
0落后 ≤ 3K(K=5 时约 15 tick)Behind ≤ 3K (≈15 ticks at K=5)加速追赶,1.0 → 1.25×(MaxNetworkTickSpeedRatio)Accelerate 1.0 → 1.25× (MaxNetworkTickSpeedRatio)
3K落后 3K ~ 300 tickBehind 3K ~ 300 ticks本地纯追帧(无需快照)Local pure fast-forward (no snapshot)
300落后 ≥ 300 tick(LagResyncThresholdTicks)Behind ≥ 300 ticks (LagResyncThresholdTicks)暂停 + 服务器快照恢复(300 tick 冷却内只暂停不请求)Pause + server-snapshot recovery (cooldown: pause only, no request)
600ack 停滞 ≥ 600 tick(PacingTimeoutTicks)Ack stall ≥ 600 ticks (PacingTimeoutTicks)服务器踢出(先广播 ClientKickedPacingTimeout 事件)Server kicks (broadcasting ClientKickedPacingTimeout first)

镜像的超前侧:超前 0 ~ K 减速至 0.3×;超前 ≥ K 暂停等服务器(输入天然领先,等一等无害)。踢出基于 ack 停滞而非哈希失配——哈希失配走恢复路径,绝不踢人。

Mirrored leading side: ahead 0 ~ K decelerates to 0.3×; ahead ≥ K pauses waiting for the server (leading on input is harmless). The kick is ack-stall-based, never hash-based — hash mismatch routes into recovery, not kickout.

快照系统 The Snapshot System

快照是"把整个世界装进一个载荷"的机制:FEntitySnapshotPayload 携带全部网络实体的记录(模板键 + UID + 类别 + flag + 逐 fragment 的 blob)、挂起命令、gap 命令、延迟生成队列、BP 异步任务记录、LockstepVarStore 变量、随机流、UID 游标,以及流场基底(保证恢复端先有导航基底再模拟任何 tick)。三条设计主线:

A snapshot is "the whole world in one payload": FEntitySnapshotPayload carries every networked entity's record (template key + UID + category + flags + per-fragment blobs), pending commands, gap commands, the deferred-spawn queue, BP async-task records, LockstepVarStore variables, random streams, the UID cursor, and flow-field substrates (so a recovering peer never simulates a tick without its navigation substrate). Three design threads:

注册表驱动Registry-driven

MassBattleSnapshotRegistry.h 是单一事实来源:一行 SYNC(Fragment, Field1, …) 同时声明快照字段与哈希字段,查询 / 视图 / 捕获 / 布局 / 恢复全部自动接线。快照 blob 保留原始字节(-0.0 不清洗),哈希折叠则规范化浮点——两条管线、两种纪律。

MassBattleSnapshotRegistry.h is the single source of truth: one SYNC(Fragment, Field1, …) line declares both snapshot and hash fields; query / views / capture / layout / restore all auto-wire. Snapshot blobs keep raw bytes (-0.0 preserved) while hash folding canonicalizes floats — two pipelines, two disciplines.

句柄 UID 化 · 对象路径化Handles→UID · Objects→path

实体句柄在快照里序列化为目标的 UniqueID(恢复后经 UID 映射重解析);UObject / 软引用序列化为对象路径(恢复端按本 World 重解析并 GC 根化)。指针永远不上网。

Entity handles serialize as the target's UniqueID (re-resolved through the UID map after restore); UObjects / soft refs serialize as object paths (world-local re-resolve + GC-rooting on restore). Pointers never travel.

池也是快照公民Pools are snapshot citizens

池内休眠的宿主 / 投射物 / 掉落同样进哈希与快照——池是锁步确定性状态,池分歧 = bug,必须被检测。恢复端按 (类型, UID) 升序把重建的池实体放回,与服务器池逐位对齐。

Pooled dormant hosts / projectiles / loot are hashed and snapshotted too — pools are lockstep-deterministic state; a pool divergence is a bug and must be detected. The restoring peer pushes rebuilt pool entities back in (type, UID) ascending order, matching the server's pools bit-for-bit.

模板注册表:每个网络实体都由 SnapshotTemplateRegistry 里的模板重建(键 = DataAsset 路径或合成键)。模板在命令到达时就幂等预注册(不等执行)——这样即使生成命令晚到,快照重建也能自足。蓝图变量同步:任务模板 BP 里声明的普通变量经 LockstepVarStore 自动进哈希与快照,恢复按变量名匹配(BP 增删变量安全)。

Template registry: every networked entity rebuilds from a template in SnapshotTemplateRegistry (key = DataAsset path or a synthetic key). Templates pre-register idempotently at command arrival (not execution) — so snapshot rebuilds are self-sufficient even if the spawn command arrived late. BP variable sync: ordinary variables declared on task-template BPs flow into hash & snapshot via the LockstepVarStore; restore matches by variable name (BP add/remove safe).

恢复验证:快照应用完毕立即用与捕获端相同的注册表重折叠一次哈希,与载荷携带的 ServerHashAtCapture 比对——匹配证明恢复保真,此后任何失配都是追帧本身的非确定性。恢复还会剥离端本地渲染 flag(选中 / 已渲染等,由本地视觉登记表重放)并递增 ResyncGeneration 令牌让 GPU 插值跳变对齐。

Restore validation: right after applying, the restoring peer re-folds the hash with the same registry as the capturer and compares against the payload's ServerHashAtCapture — a match proves restore fidelity, so any later mismatch is fast-forward non-determinism. Restore also strips peer-local render flags (selected / rendering — replayed by the local visual registry) and bumps the ResyncGeneration token so GPU interpolation snaps.

大负载自动分流 Auto-Flood: UDP RPC + TCP Blocks

引擎可靠 RPC 有 ~64KB bunch 上限且会饱和可靠通道。MassBattle 的方案:SendLockstepCommandToClients / SendLockstepCommandToServer 在负载估算达到 MassBattle.AutoFloodSwitchBytes(CVar,默认 16KB)时自动切换为 TCP 数据块 + 轻量 UDP 引用命令——块先走自建 TCP 通道(压缩 + 双层 CRC + 分块重组),引用命令只带 BlockId 与 CRC,到达时从块缓存解包执行。

Engine reliable RPCs have a ~64KB bunch limit and saturate the reliable channel. MassBattle's answer: when the estimated payload reaches MassBattle.AutoFloodSwitchBytes (CVar, default 16KB), SendLockstepCommandToClients / SendLockstepCommandToServer auto-switch to a TCP data block + lightweight UDP reference command — the block rides the custom TCP channel first (compressed, dual-layer CRC, chunked reassembly); the reference command carries only BlockId + CRC and unwraps from the block cache at execution.

两条独立路径的到达竞态——暂存冻结:UDP 引用命令与 TCP 块的 flush 顺序无保证。块未到时命令不丢弃(丢弃 = 永久缺失该命令效果,本地恢复也修不好),而是暂存并冻结本地时间线:本地 tick 停在 ExecuteTick,块到达后同 tick 执行——与服务器序列完全一致;10 秒超时(如 TCP 断链)才判失步。宿主组件未复制到的生成命令(*Component.Spawn / Obstacle.Spawn)走同款暂存冻结。已被执行的数据块按重放窗保留——本地恢复的追帧重放还需要它们。

Two independent paths race — stage & freeze: the UDP reference and the TCP block flush in arbitrary order. If the block hasn't landed the command is not dropped (dropping = permanently missing effects no local recovery can repair) but staged with the local timeline frozen: the local tick parks at ExecuteTick and the command executes the moment the block lands — identical sequence to the server; only a 10s timeout (dead TCP) escalates to desync. Spawn commands whose host component hasn't replicated (*Component.Spawn / Obstacle.Spawn) stage-and-freeze the same way. Executed blocks are retained for the replay window — local-recovery fast-forwards still need them.

流场基底是这套机制的典型用户:SendLockstepCommandToClients_RebuildFlowField 导出场的量化基底(MB 级),恒走块路径,支持全量 / 差分 / 自动择小(Auto,默认)三种形态;动态 NavMesh 重建完成时自动重导出。全端导入同一份字节后本地跑同一套 A*——引擎 NavMesh 查询链彻底退出锁步路径。

Flow-field substrates are the canonical user of this machinery: SendLockstepCommandToClients_RebuildFlowField exports the field's quantized substrate (MB-scale), always rides the block path, in Full / Incremental / Auto (default) wire forms; dynamic-navmesh rebuilds re-export automatically. Every peer imports the same bytes then runs the same A* locally — the engine navmesh query chain leaves the lockstep path entirely.

客户端命令闸门与安全 Client Command Gate & Security

客户端上行命令在 ReceivePlayerCommand 唯一收口点执法,默认拒绝(default-deny):

Client uplink commands are enforced at the single choke point ReceivePlayerCommand, default-deny:

闸门Gate规则Rule
服务器权威 tagServer-authority tagsAgentComponent.Spawn / SetAgentTransform / Despawn、Projectile/LootComponent.Spawn / Despawn、Obstacle.*、FF.RebuildFlowField、Game.Pause / Resume、Player.Leave —— 客户端发出即拒绝(攻击信号级 Error 日志),且永不可加入白名单。AgentComponent.Spawn / SetAgentTransform / Despawn, Projectile/LootComponent.Spawn / Despawn, Obstacle.*, FF.RebuildFlowField, Game.Pause / Resume, Player.Leave — client-issued ones are rejected (attack-signal Error log) and can never be allow-listed.
游戏白名单Game whitelist其余 tag 必须在 ClientAllowedCommandTags(BP GameMode 默认值)或经 RegisterClientAllowedCommandTag 运行时注册。模式三档:Enforce(默认)/ LogOnly(迁移观察期)/ Disabled。启动时打印 [CMD_WHITELIST] 生效策略——Enforce 且白名单为空会警告"所有客户端命令都将被拒绝"。Everything else must be in ClientAllowedCommandTags (BP GameMode defaults) or registered at runtime via RegisterClientAllowedCommandTag. Modes: Enforce (default) / LogOnly (migration) / Disabled. Startup prints the effective [CMD_WHITELIST] policy — Enforce with an empty set warns that every client command will be rejected.
准入配额Admission quotas每玩家每 tick ≤ MaxClientCommandsPerTick(16)条;服务器待广播队列 ≤ MaxPendingCommands(4096)。客户端本地队列 16384 硬上限,溢出直接请求全量重同步。≤ MaxClientCommandsPerTick (16) per player per tick; server pending-broadcast ≤ MaxPendingCommands (4096). The client-local queue hard-caps at 16384 — overflow requests a full resync.
输入钳制Input clamping客户端可控的 ExtraDeferTicks 钳到 [0, 1024],IssuedTick 钳到服务器 tick 周围窗口;伪造"超前 ack"会被钳制并记作弊信号。Client-controlled ExtraDeferTicks clamps to [0, 1024] and IssuedTick to a window around the server tick; fabricated ahead-of-server acks are clamped and logged as a cheat signal.
失步隔离Desync quarantine确认失步的客户端其上行命令被丢弃(有日志),直到恢复完成或 ack 哈希重新匹配(本地恢复路径的自愈出口)。A confirmed-desynced client's uplink commands are dropped (logged) until recovery completes or its acked hash matches again (the self-heal exit for the local-recovery path).

内置命令族 Built-in Command Tags

GameState::ExecuteNetCommand 内置处理的命令(游戏自定义 tag 之外的全部);处理完后无条件转发蓝图钩子 ReceiveLockstepCommandFromServer:

Commands handled natively by GameState::ExecuteNetCommand (everything besides game tags); after the built-in branch the Blueprint hook ReceiveLockstepCommandFromServer always fires:

CommandTag载荷Payload作用Effect
AgentComponent.SpawnInt[0]=LockstepNetID, Int[1]=OwningPlayerSlot;Name[0]=DataAsset 路径;Float[0..6]=生成变换Int[0]=LockstepNetID, Int[1]=OwningPlayerSlot; Name[0]=DataAsset path; Float[0..6]=spawn transform按 NetID 定位 AgentComponent,双端本地铸造同一 UID 生成 agent(组件未复制则暂存冻结)Locates the AgentComponent by NetID; both peers mint the same UID locally (stage-freeze if the component hasn't replicated)
AgentComponent.SetAgentTransformInt[0]=UID, Int[1]=轴标志(bit0=位置,bit1=旋转,bit2=缩放), Int[2..4]=网格坐标;Float[0..2]=偏移, [3..6]=四元数, [7]=缩放Int[0]=UID, Int[1]=axis flags (bit0=loc, bit1=rot, bit2=scale), Int[2..4]=grid coords; Float[0..2]=offsets, [3..6]=quat, [7]=scaleComponent→Entity 模式:把角色驱动的变换写入实体(服务器按 K 缓冲速度预测排期)Component→Entity mode: writes the character-driven transform into the entity (server schedules with K-buffer velocity prediction)
AgentComponent.Despawn
ProjectileComponent.Despawn
LootComponent.Despawn
Int[0]=UIDInt[0]=UIDUID 驱动确定性销毁:置 PendingDestroy 旗 + 销毁队列条目同 tick 生效UID-driven deterministic destroy: PendingDestroy flag + destroy-queue entry land the same tick
ProjectileComponent.Spawn
LootComponent.Spawn
Int[0]=LockstepNetID;Name[0]=DataAsset;Float[0..6]=变换Int[0]=LockstepNetID; Name[0]=DataAsset; Float[0..6]=transform镜像 AgentComponent.Spawn(无属主槽位)Mirrors AgentComponent.Spawn (no owner slot)
Obstacle.SpawnInt[0]=UID, Int[1]=类型(0盒/1胶囊/2样条), Int[2]=NetID, Int[3]=标志位;Name[0]=组件路径;Float[0..]=变换+几何Int[0]=UID, Int[1]=type (0 box / 1 capsule / 2 spline), Int[2]=NetID, Int[3]=flag bits; Name[0]=component path; Float[0..]=transform + geometry命令路由障碍物生成(UID 来自服务器预分配的障碍物槽位,接收端标记槽位)Command-routed obstacle spawn (UID from the server-preallocated obstacle slot bitmap; receivers mark the slot)
Obstacle.SyncTransform / Obstacle.DestroyInt[]=UID 数组;Float[0..6]=变换(Sync)Int[]=UID array; Float[0..6]=transform (Sync)同步移动 / 确定性销毁(槽位释放与销毁同 tick,位图双端一致)Synced movement / deterministic destroy (slot free + destroy same tick, identical bitmaps)
Game.Pause / Game.Resume—暂停 / 恢复模拟Pause / resume the simulation
Player.LeaveInt[0]=PlayerSlot, Int[1]=UIDInt[0]=PlayerSlot, Int[1]=UID玩家退出时按 UID 确定性销毁其每个 agentDeterministically destroys each of a leaving player's agents by UID
FF.RebuildFlowField块字节(Name[0]=场名, 可选 Name[1]=Delta)block bytes (Name[0]=field, optional Name[1]=Delta)全端导入流场基底(幂等,按内容哈希跳过;差分基准脱节自动请求全量重发)All peers import the flow-field substrate (idempotent via content hash; a delta whose base drifted auto-requests a full re-send)

使用指南:从零接入一套联机玩法 Usage Guide: Wiring a Networked Game from Zero

以下全部取自联机演示地图 GameMap_HordeSurvival_Replicated 的三个蓝图:一个 GameMode、一个 GameState、一个玩家 Character。这是一个"尸潮生存"玩法:玩家被僵尸围攻,随身带 0.75s 一跳的范围伤害光环,服务器持续刷僵尸波次直到 10000 上限。麻雀虽小,覆盖了接入所需的全部模式。

Everything below is taken from the three Blueprints of the networked demo map GameMap_HordeSurvival_Replicated: one GameMode, one GameState, one player Character. It's a horde-survival setup: the player is swarmed by zombies, carries a 0.75s-ticking radial damage aura, and the server keeps spawning waves up to the 10,000 cap. Small, but it covers every pattern you need.

接入清单(五步)Integration checklist (five steps)

  1. 换基类:项目 GameMode 继承 AMassBattleNetworkGameModeBase,GameState 继承 AMassBattleNetworkGameStateBase,PlayerController 继承 AMassBattleNetworkPlayerControllerBase;DefaultPawn 设为携带 UMassBattleAgentComponent 的 Character。GameMode 上 bEnableNetworkSync(默认开)决定会话是否锁步。
  2. 配置模拟:GameState BeginPlay 调 Set Sim Config(步长 0.0333、Deterministic=true)与 Set Physics Substeps——两端必须一致。
  3. 注册模板:把每个兵种 DataAsset 做成模板、挂上 Navigation(流场)、按确定性 Key 注册进网络模板注册表。
  4. 填白名单:把所有客户端可发的命令 tag 填进 GameMode 的 ClientAllowedCommandTags(演示中即 Player.DealDmg)。
  5. PIE 双端验证:Play 模式选 2 Player / Net Mode = Listen Server;开着 NetworkDebugWidget 打一局,观察哈希一致与实体数同步。

① GameState 初始化:模拟配置 + 模板注册① GameState init: sim config + template registration

GameState 的 BeginPlay 完成四件事:定模拟参数、注册兵种模板、创建调试 UI、延迟 130 tick 后启动刷怪循环。模板注册是关键——所有端都要在命令到达前把模板注册好,键必须一致:

The GameState's BeginPlay does four things: pin sim parameters, register unit templates, create debug UI, and start the wave loop after a 130-tick delay. Template registration is the crux — every peer must register templates under identical keys before commands arrive:

Event BeginPlay→Set Sim Config
(0.0333 · Deterministic ✓ · FrameSpreading ✗)
→Set Physics Substeps (1)→Make And Register Template→CreateWidget ×3
(NetworkDebug / EntityCount&FPS / GraphicsQuality)
→Add To Player Screen→Delay Mass Battle Ticks (130)→S2C_AISpawn
Make And Register Template(每个兵种一条链,Sequence 并行两路;以 Zombie01 为例)Make And Register Template (one chain per unit, two parallel Sequence legs; Zombie01 shown)
Get Actor Of Class
(BP_FlowFieldCanvas)
→Set FFC(缓存场引用cache field ref)→Make Template Data From Data Asset
(AgentConfig_Zombie01)
→Set Zombie T1(存变量store var)→Navigation
(Fragment=Navigation · FlowFieldToUse=FFC)
→Register Network Template Data
(Key="Zombie01")
要点:Key 是跨端契约——命令载荷里只传 "Zombie01" 这个名字,各端拿名字到本地注册表查模板;Navigation 节点把流场引用写进模板的 Navigation fragment,之后生成的每个僵尸都朝场目标寻路。Key points: the Key is the cross-peer contract — commands carry only the name "Zombie01" and each peer resolves it in its local registry; the Navigation node writes the flow-field reference into the template's Navigation fragment so every zombie paths toward the field goal.

为什么用 Delay Mass Battle Ticks 而不是普通 Delay:前者按模拟 tick 对齐(130 tick ≈ 30Hz 下 4.3 秒),各端时间线一致;普通墙钟 Delay 只用于纯本地/纯权威流程(如重生等待),绝不能拿来驱动会影响模拟的逻辑。

Why Delay Mass Battle Ticks instead of a plain Delay: the former aligns to simulation ticks (130 ticks ≈ 4.3s at 30Hz), identical on every peer; wall-clock Delays are fine for purely local / authority-only flows (like the respawn wait) but must never drive simulation-affecting logic.

② 服务器波次:AI.Spawn 命令② Server waves: the AI.Spawn command

刷怪循环只活在服务器上(Switch Has Authority → Authority 分支),每 5 tick 发一条 AI.Spawn,直到场上 10000 个 agent 为止。命令只携带模板键数组——真正的生成发生在所有端的命令钩子里:

The wave loop lives only on the server (Switch Has Authority → Authority leg), sending one AI.Spawn every 5 ticks until 10,000 agents are alive. The command carries just the template-key array — the actual spawning happens in the command hook on every peer:

S2C_AISpawn(GameState 自定义事件;130 tick 后首触发)S2C_AISpawn (GameState custom event; first fired after the 130-tick delay)
Switch Has Authority→ Authority →Send Lockstep Command To Clients
(Tag="AI.Spawn" · NamePayload=["Zombie01","Zombie02"])
→Branch
(Agent Count < 10000)
→ True →Delay Mass Battle Ticks (5)→回到 Send(循环)back to Send (loop)
Event Lockstep Command From Server(GameState 事件,双端同一 ExecuteTick 触发)Event Lockstep Command From Server (GameState event, fires on both peers at the same ExecuteTick)
Event Lockstep Command From Server (Cmd)→Break Mass Battle Net Command→Set Int / Float / Name Payload(存本地变量便于取用stash to local vars)→Switch Name (CommandTag)
分支 "AI.Spawn"(Sequence 两路并行)Leg "AI.Spawn" (two parallel Sequence legs)
Get Network Template Data
(Key = NamePayload[0] = "Zombie01")
→Spawn Agents By Template Circular
(Quantity=8 · Team=1 · 半径10000/64段圆形 · AutoActivated ✓)
Get Network Template Data
(Key = NamePayload[1] = "Zombie02")
→Spawn Agents By Template Circular
(Quantity=2 · Team=1 · 同形状)
每条 AI.Spawn 在每个端各生成 8 个 Zombie01 + 2 个 Zombie02。生成函数在命令钩子内调用 ⇒ 走确定性命令路径(UID 双端同铸、随机流同种子),各端成员集合逐 tick 一致。AI.Spawn 由服务器发出,无需进客户端白名单。Each AI.Spawn spawns 8× Zombie01 + 2× Zombie02 on every peer. Spawning inside the command hook ⇒ the deterministic command path (UIDs minted identically, random streams identically seeded), so membership sets agree tick-for-tick. AI.Spawn is server-issued — no client whitelist entry needed.

③ 玩家:移动走引擎,Agent 走锁步③ The player: engine-driven movement, lockstep-driven agent

玩家 Character 的移动用标准 UE 角色移动(Enhanced Input → Add Movement Input),由引擎复制到服务器。玩家的 Mass agent 则由框架自动桥接:服务器每个网络 flush tick 采样 pawn 上 AgentComponent 的变换(按 K 缓冲做速度外推),广播 AgentComponent.SetAgentTransform,全端把实体吸附到角色位置——Character 走引擎复制、agent 走锁步命令,两条腿各走各的。输入分支的三个守卫值得照抄:

The player Character moves with standard UE character movement (Enhanced Input → Add Movement Input), engine-replicated to the server. The player's Mass agent is bridged automatically by the framework: every network flush tick the server samples the pawn's AgentComponent transform (K-buffer velocity extrapolation) and broadcasts AgentComponent.SetAgentTransform; all peers snap the entity to the character — Character via engine replication, agent via lockstep commands, two legs walking their own roads. The input branch's three guards are worth copying verbatim:

IA_Move (Triggered)→Branch (NOT isDead AND NOT FastForwarding AND NOT GamePaused)→ True →Add Movement Input ×2
(X→Right · Y→Forward)
NOT FastForwarding:追帧期间玩家输入会造成追帧后状态偏移,直接掐掉;NOT GamePaused:暂停(含失步恢复暂停)时冻结操控;NOT isDead:死后禁输入。NOT FastForwarding: player input during a fast-forward would skew post-FF state, so it's cut off; NOT GamePaused: freeze control while paused (including desync-recovery pauses); NOT isDead: no input after death.

④ 自定义客户端命令:Player.DealDmg 光环④ A custom client command: the Player.DealDmg aura

伤害光环是"客户端发起、全端执行"自定义命令的完整范例。发送端在玩家 Character 上(仅本地控制端运行):0.75 秒循环定时器,每跳把自己的位置、agent UID 和服务器随机掷出的减益类型发上去:

The damage aura is a complete example of a client-originated, all-peers-executed custom command. The sender lives on the player Character (local controller only): a 0.75s looping timer ships the aura position, the agent UID, and the server-rolled debuff type every tick of the timer:

玩家 Character(仅 IsLocallyControlled 分支启动;BeginPlay 里 Delay 0.25s 后首触发)Player Character (started only under IsLocallyControlled; first fired after a 0.25s Delay in BeginPlay)
C2S_DealDamage(自定义事件custom event)→Set Timer Delegate
(0.75s · Looping · 自身事件)
→Send Lockstep Command To Server
(Tag="Player.DealDmg")
↳Int Payload = [ EntityHandleToUniqueID(AgentHandle), DebuffType ]·Float Payload = [ ActorLocation.X, .Y, .Z ]
GameState 命令钩子 "Player.DealDmg" 分支(双端执行)GameState command hook, "Player.DealDmg" leg (executes on both peers)
Get Float Payload [0..2] → Make Vector
(Origin / HitFromLocation)
·Get Int Payload [0] → Unique ID To Entity Handle
(Dmg Instigator / Causer)
·Get Int Payload [1] → Select ×3
(火/冰 · 是否持续伤 · 是否减速)
↓Apply Radial Damage And Debuff
(Damage=100 · DmgRadius=750 · Crit×2/10% · UseFalloff ✓ · Query=All[AgentTag,Team1]/None[DyingTag] · Debuff: Launch500 / 持续伤60×6段×0.5s / 减速4s)
减益类型的随机掷值在服务器上做(BeginPlay 里 Switch Has Authority → Random Integer In Range(0,1)),但结果只通过命令载荷传播——远端不自己掷,各端用的都是 IntPayload[1] 里的同一个值。这是"非确定值上链"的标准姿势:在哪端掷不重要,重要的是掷一次、随命令走。The debuff type is rolled on the server (BeginPlay: Switch Has Authority → Random Integer In Range(0,1)), but the result travels only inside the command payload — remotes never roll their own; every peer uses the same IntPayload[1]. That's the standard posture for putting a non-deterministic value on the wire: where it's rolled doesn't matter; what matters is roll once, ride the command.

别忘白名单:Player.DealDmg 是客户端发起的 tag,必须加入 GameMode 的 ClientAllowedCommandTags(类默认值 → MassBattle|Network)。闸门默认 Enforce——漏配的命令会被静默丢弃(日志 [CMD_REJECT] … reason=not-allowed),表现为"技能时灵时不灵"。迁移存量项目可先切 LogOnly 观察日志再收紧。

Don't forget the whitelist: Player.DealDmg is a client-issued tag and must be added to the GameMode's ClientAllowedCommandTags (Class Defaults → MassBattle|Network). The gate defaults to Enforce — a missing entry silently drops the command (log: [CMD_REJECT] … reason=not-allowed), which looks like "the skill works sometimes". Migrating legacy projects can start in LogOnly, watch the log, then tighten.

⑤ 流场寻路同步⑤ Flow-field pathing sync

僵尸朝玩家寻路依赖流场,而引擎 NavMesh 查询不是确定性的——所以基底必须同步。GameMode 的 BeginPlay 只做两步:先在服务器上全量重建场,再把基底锁步广播出去:

Zombie pathing toward the player rides a flow field, and engine navmesh queries aren't deterministic — so the substrate must sync. The GameMode's BeginPlay does exactly two things: rebuild the field on the server, then lockstep-broadcast the substrate:

Event BeginPlay(GameMode)→Get All Actors Of Class (BP_FlowFieldCanvas)→For Each Loop→Update Flow Field (Full Rebuild ✓)→Send Lockstep Command To Clients Rebuild Flow Field
(Extra Defer Ticks=120 · Sync Mode=Auto)
ExtraDeferTicks=120 给 MB 级 TCP 块留传输余量;Auto 形态自动在全量 / 差分间择小发送。全端(含服务器)导入同一份字节后,各自在本地副本上跑同一套 A*——双端寻路结果构造性一致,连 NavMesh 多边形内部顺序这种引擎实现细节都不再影响锁步。晚加入者由快照携带基底自动补齐;动态 NavMesh 重建完成时框架自动重导出重播。ExtraDeferTicks=120 leaves transit headroom for the MB-scale TCP block; Auto picks whichever of full/delta is smaller. Every peer (server included) imports the same bytes and runs the same A* on its local replica — pathing is consistent by construction, down to ignoring engine details like navmesh polygon internal order. Late joiners get the substrate riding the snapshot; dynamic-navmesh rebuilds re-export automatically.

⑥ 死亡与重生⑥ Death & respawn

On Death(AgentComponent 事件)→Stop C2S_DealDamage(清光环定时器clear aura timer)→Set Visibility (Hidden)(血条)→Set Simulate Physics (Mesh ✓)→Disable Input→Delay 5s→ Authority →Get PlayerStarts → Random → SpawnActor(自身类own class)→Possess(新 pawnnew pawn)→Destroy Actor(旧 pawnold pawn)
死亡表现(物理、隐藏)是端本地效果;重生是纯服务器流程——服务器 SpawnActor + Possess,新 pawn 经引擎复制到达各端,其 AgentComponent 走标准的 AgentComponent.Spawn 锁步生成(GameMode 在 tick 边界冲刷,玩家会等到其 resync/late-join 完成后才刷,保证"先有世界快照再有玩家")。旧的死亡 pawn 销毁时其 agent 由框架的销毁管线确定性回收。Death presentation (physics, hiding) is peer-local; respawning is a pure server flow — the server SpawnActor + Possess, the new pawn reaches peers via engine replication, and its AgentComponent goes through the standard AgentComponent.Spawn lockstep spawn (the GameMode flushes at tick boundaries and holds a player's spawn until their resync / late-join completes — "world snapshot before player"). The dead pawn's agent is deterministically recycled by the framework's destroy pipeline.

⑦ 调试 UI⑦ Debug UI

演示在 BeginPlay 里挂了三个 widget:WB_NetworkDebugWidget(网络状态:tick 差、调速比、命令队列深度、哈希、失步计数——来自 GetNetworkStats)、WB_EntityCountAndFPS、WB_GraphicsQuality。联机调试时保持 NetworkDebugWidget 常开,双端 tick 差稳定在 ±K 内、哈希一致即为健康。

The demo mounts three widgets at BeginPlay: WB_NetworkDebugWidget (network state: tick delta, speed ratio, command-queue depth, hashes, desync count — from GetNetworkStats), WB_EntityCountAndFPS, and WB_GraphicsQuality. Keep NetworkDebugWidget up while testing: a healthy session shows both peers' tick delta steady within ±K and matching hashes.

调试与排障 Debugging & Forensics

工具 / 开关Tool / switch作用What it does
bEnablePerEntityHashDiag逐实体哈希诊断总闸:记录 128-tick 环形历史 + 逐 fragment 分量哈希;失步时转储 [DIVERGE_TICK] / [HASH_ENT] / [CLI_AGH] / [SRV_AGH] 等日志,双端同 tick 对照即可定位最先失步的实体与字段。双端都要开。Master gate for per-entity hash diagnostics: a 128-tick ring history + per-fragment component hashes; on desync it dumps [DIVERGE_TICK] / [HASH_ENT] / [CLI_AGH] / [SRV_AGH] logs — diffing both peers' same-tick dumps locates the first diverging entity and field. Enable on BOTH peers.
bLogPerEntityHashes每 tick 打印逐实体哈希与 [HASH_TICK] 校验行(日志量巨大,仅排查时开)。Per-tick per-entity hash logging plus the [HASH_TICK] validation line (huge log volume — hunting only).
失步自动取证器Desync auto-forensics首个发散 tick 当帧暂停双端并全量转储世界状态到 Saved/MassBattleDesync/dump_<role>_t<tick>.mbdmp,用 Tools/desync_dump_diff.py 离线逐字段比对,打印每一个不同的值;配确定性输入 fuzzer(INI 种子复现"人类"操作序列)。At the first divergent tick it pauses both peers in-frame and dumps full world state to Saved/MassBattleDesync/dump_<role>_t<tick>.mbdmp; Tools/desync_dump_diff.py diffs offline and prints every differing value; a seeded input fuzzer replays a deterministic "human" session from an INI seed.
MassBattle.NetEmulation.*TCP 路径网络模拟:LagMs / JitterMs / DropPercent / BandwidthLimitKBs / RandomSeed——压力测试缺块 → 失步 → 恢复链路。TCP-path network emulation: LagMs / JitterMs / DropPercent / BandwidthLimitKBs / RandomSeed — stress the missing-block → desync → recovery chain.
bAuditUIDRegistrationUID 注册审计:报 [UID_REG_MISS](生成点缺注册),只检测不治愈。UID registration audit: reports [UID_REG_MISS] (a spawn site missing registration); detect-only, never heals.
bDisableFieldRegistrationAudit字段注册审计(编辑器默认开):报 [FIELD_REG_AUDIT]——网络实体上未注册 fragment / 字段的运行时变化(静默失步源),报错信息直接点名要改哪个注册表宏。Field-registration audit (on by default in editor): reports [FIELD_REG_AUDIT] — runtime changes of unregistered fragments / fields on networked entities (a silent desync source); the message names the exact registry macro to edit.
[CMD_REJECT] / [CMD_WHITELIST]客户端命令闸门日志:拒绝原因(not-allowed / authority-tag)与启动时的生效策略。Client gate logs: rejection reason (not-allowed / authority-tag) and the effective startup policy.
[BLOCK_WAIT] / [SPAWN_WAIT] / [BLOCK_TIMEOUT]TCP 块 / 宿主组件的暂存等待与超时——频繁出现说明网络或复制跟不上。Staged waits and timeouts for TCP blocks / host components — frequent hits mean the network or replication can't keep up.
[DESYNC_PENDING] … [RESYNC]防抖确认中 → 确认失步 → 快照应用 / 恢复哈希校验的完整链条;[RESYNC_GATE] 是服务器侧的隔离与放行记录。The full chain from debounce confirmation → confirmed desync → snapshot apply / restore-hash validation; [RESYNC_GATE] is the server-side quarantine & release log.

硬规则清单 Hard Rules

每一条都对应源码里的守卫或惨痛教训(详见技能库 lockstep-lessons)。违反的后果统一是:轻则 checkf 崩溃,重则静默失步——最坏的那种 bug。

Each maps to a guard or a scar in the source (see the lockstep-lessons reference). The penalty for violation is uniform: a checkf crash if you're lucky, silent desync if you're not — the worst kind of bug.

#规则Rule
1网络会话中绝不在锁步命令路径之外生成 / 销毁 Agent。public spawn API 有 IsOffLockstep 守卫直接拦截;服务器权威意图走 SendLockstepCommandToClients,客户端意图走白名单命令。In networked sessions never spawn / destroy agents outside the lockstep command path. Public spawn APIs carry an IsOffLockstep guard that blocks you; server intent goes through SendLockstepCommandToClients, client intent through whitelisted commands.
2单位任务(AgentsMoveTo / AgentsChaseAttack / 阵型…)必须在命令钩子内创建——本地创建的任务只写本端可见的 fragment,必然失步。Unit tasks (AgentsMoveTo / AgentsChaseAttack / formation…) must be created inside the command hook — locally created tasks write fragments only this peer can see; desync guaranteed.
3LockstepVarStore 变量(任务模板 BP 变量)只能在任务 tick 处理器与命令流中写入;UI / 广播里写 = 分叉。resync 重建窗口内只能写可推导状态,其余在 OnResyncRecovered 重算。LockstepVarStore variables (task-template BP variables) may only be written from task tick handlers and the command flow; writing from UI / broadcasts = divergence. During the resync rebuild window write only derivable state — recompute the rest in OnResyncRecovered.
4非确定值(随机、时间)要么用框架的确定性随机流,要么掷一次、随命令走(演示的减益类型即是)。Non-deterministic values (randomness, time): use the framework's deterministic streams, or roll once and ride the command (the demo's debuff type does exactly this).
5客户端可发的 tag 记得进 ClientAllowedCommandTags;服务器权威 tag 永远别想白名单。Client-issuable tags belong in ClientAllowedCommandTags; server-authority tags can never be whitelisted.
6修改 hash / snapshot 注册表字段会改变所有 per-tick 哈希——双端必须运行相同版本;新增字段后跑一轮 PIE 双端 desync 验证。Editing hash / snapshot registry fields changes every per-tick hash — both peers must run identical builds; after adding fields, run a PIE dual-peer desync pass.
7给 fragment 加新 UPROPERTY 时同步决定其去向:进快照+哈希注册表,或加豁免清单注明原因——未注册的运行时可变字段是静默失步源(字段审计器会点名)。When adding a UPROPERTY to a fragment, decide its fate in the same change: snapshot+hash registries, or the exclusion list with a reason — an unregistered runtime-mutable field is a silent desync source (the field auditor will name it).
8渲染 / 选择等端本地状态不入同步(框架已剥离并交本地视觉登记表重放);别在模拟代码里读写"本端才有的"对象状态。Peer-local render / selection state never syncs (the framework strips and replays it via the local visual registry); don't read/write "only-exists-here" object state from simulation code.
9大负载交给 SendLockstepCommandToClients 自动分流;定向单播 SendLockstepCommandToClient 无分流,注意 RPC bunch 上限。Leave large payloads to SendLockstepCommandToClients auto-flood; the targeted unicast SendLockstepCommandToClient has no splitting — mind the RPC bunch limit.
10墙钟时间只做表现;影响模拟的时序一律用 tick(Delay Mass Battle Ticks、ExtraDeferTicks)。Wall-clock time is presentation-only; simulation-affecting timing is always ticks (Delay Mass Battle Ticks, ExtraDeferTicks).

关键源文件 Key Source Files

文件File职责Role
Network/MassBattleNetworkGameModeBase服务器命令收口(闸门/配额/K 缓冲排程)、late-join / resync 派发、流场基底导出、踢出Server command choke point (gates / quotas / K-buffer scheduling), late-join / resync dispatch, flow-field substrate export, kickout
Network/MassBattleNetworkGameStateBase下行多播 RPC、内置命令执行(ExecuteNetCommand)、蓝图命令钩子、客户端延迟失步校验Downlink multicast RPCs, built-in command execution (ExecuteNetCommand), the Blueprint command hook, client-side deferred desync checks
Network/MassBattleNetworkPlayerControllerBase上行 Server RPC 与 BP 便捷入口、TCP 快照控制面、ack / resync 上报Uplink Server RPCs + BP convenience entries, TCP snapshot control plane, ack / resync reporting
Subsystems/MassBattleNetworkSubsystem命令队列(去重/排序/暂存冻结)、快照采集与应用、本地恢复、追帧、客户端调速、哈希布局、模板与宿主注册表、TCP 传输生命周期Command queue (dedup / sort / stage-freeze), snapshot capture & apply, local recovery, fast-forward, client pacing, hash layouts, template & host registries, TCP transport lifecycle
Processors/MassBattleNetworkProcessor统一并行查询上的逐实体哈希折叠 + 周期快照捕获 + VarStore 折叠 + 字段注册审计Per-entity hash folding + periodic snapshot capture + VarStore fold + field-registration audit on one unified parallel query
Network/MassBattleSnapshotRegistry快照 / 哈希双注册表与豁免清单——同步内容的单一事实来源The snapshot / hash dual registries + exclusion lists — single source of truth for what syncs
Network/MassBattleSnapshotStructsFEntitySnapshotPayload / FEntitySnapshotRecord / blob 等快照数据结构Snapshot data structures: FEntitySnapshotPayload / FEntitySnapshotRecord / blobs
Network/MassBattleHashFold共享哈希折叠原语(-0.0 / 反规格化数规范化、路径指纹)Shared hash-fold primitives (-0.0 / subnormal canonicalization, path fingerprint)
Network/MassBattleLockstepVarStoreBP 模板变量的自动锁步同步(进哈希 + 进快照,按名恢复)Automatic lockstep sync of BP template variables (hashed + snapshotted, name-matched restore)
Network/MassBattleSnapshotTcpTransport自建 TCP 通道:快照与大负载块的传输、认证、心跳、分块重组、网络模拟The custom TCP channel: snapshot & large-block transfer, auth, heartbeats, chunked reassembly, net emulation
Network/MassBattleNetworkRelayDedicated 服务器转发层(哈希投票 / 快照灾备,ListenServer 之外的模式)Dedicated-server relay layer (hash voting / snapshot disaster recovery — the non-listen modes)

术语表 Glossary

术语Term定义Definition
Lockstep所有端在同一逻辑 tick 以相同输入运行相同模拟——不交换状态而结果一致。All peers run the same simulation at the same logical tick with the same inputs — identical results without exchanging state.
K-Buffer故意的输入延迟(tick 数),保证所有端在执行前收到命令。K = ceil(MinInputBufferMs / 每tick毫秒)。Deliberate input delay (in ticks) ensuring every peer receives a command before execution. K = ceil(MinInputBufferMs / msPerTick).
ExecuteTick命令执行的逻辑 tick = 调度基准 + K + ExtraDeferTicks。The logical tick a command executes at = schedule base + K + ExtraDeferTicks.
ServerExecSequence服务器分配的 tick 内权威序号,与 ExecuteTick 一起构成命令全序。Server-assigned intra-tick authority order; with ExecuteTick it forms the total command order.
UniqueID (UID)每实体的稳定确定性整数标识,全机器一致;命令与快照里句柄一律以 UID 表示。Stable deterministic integer identity per entity, identical on every machine; handles travel as UIDs in commands and snapshots.
LockstepNetID服务器分配、引擎复制的宿主(组件/Actor)标识——命令据此定位绑定组件,与实体 UID 是两回事。Server-assigned, engine-replicated host (component/actor) identity — commands locate bound components through it; a different thing from the entity UID.
CityHash64非加密哈希,用于逐实体 / 逐 tick 状态折叠。快且确定。Non-cryptographic hash used for per-entity / per-tick state folding. Fast and deterministic.
Desync / Resync失步(哈希失配)/ 重同步(快照重建 + 追帧恢复)。Desynchronization (hash mismatch) / resynchronization (snapshot rebuild + fast-forward recovery).
Snapshot PayloadFEntitySnapshotPayload:整个世界的一份可传输快照(实体 blob + 命令 + 任务 + 变量 + 基底…)。FEntitySnapshotPayload: one transmissible snapshot of the whole world (entity blobs + commands + tasks + variables + substrates…).
Auto-Flood大负载自动从 UDP RPC 切换到 TCP 数据块 + 引用命令的机制(阈值 16KB)。The mechanism auto-switching large payloads from UDP RPC to a TCP data block + reference command (16KB threshold).
Stage & Freeze依赖(TCP 块 / 宿主组件)未到时暂存命令并冻结本地时间线,到达后同 tick 执行。When a dependency (TCP block / host component) hasn't arrived, stage the command and freeze the local timeline; execute at the same tick once it lands.
Fast-Forward (FF)不限步调地倍速推进本地模拟以追赶目标 tick(恢复或落后追赶)。Unpaced multi-speed local simulation to catch up to a target tick (recovery or lag catch-up).
Substrate流场基底:场自身的量化数据结构(网格 / 导航图),锁步广播、全端导入。Flow-field substrate: the field's own quantized data structure (grid / nav graph), lockstep-broadcast and imported everywhere.
MassBattle 网络同步使用指南 · 依据 2026-10 源码树与 GameMap_HordeSurvival_Replicated 演示 · 交互式时间线源自原《MassBattle Network Lockstep Architecture》科普页(Leroy Works, 2026-06-11),已随现行代码修订文字内容。 MassBattle Network Sync Guide · verified against the 2026-10 source tree and the GameMap_HordeSurvival_Replicated demo · the interactive timeline originates from the earlier "MassBattle Network Lockstep Architecture" explainer (Leroy Works, 2026-06-11); prose updated to match the current code.