TL;DR — World Server는 고정된 tick을 기준으로 게임 로직을 처리합니다. 밀린 tick을 건너뛰거나 누적된 tick의 delta를 하나로 합쳐서 계산하는 방식 대신, 한 번에 최대 N tick(현재 N=4)을 순서대로 처리하게 됩니다. 이 과정에서 중간 여러 번의 tick 계산 결과를 한 번에 합칠 수 있는 데이터와, 매 tick 마다 갱신이 필요한 데이터를 구분하고 계산 순서를 보존하여 클라이언트에게 전송합니다.
Table of contents
Open Table of contents
- 들어가며
- 1. NetworkRuntime 다음에는 누가 시간을 결정하는가
- 2. Tick을 결정하는 기준
- 3. 예정된 Deadline을 기준으로 Tick Schedule을 유지한다
- 4. 늦어진 Fixed-Step을 Bounded Catch-Up으로 처리
- 5. World Coordinator가 시간축을 소유
- 6. 하나의 Catch-Up Batch 처리
- 7. Replaceable State와 Durable Event를 분리
- 8. 100명 × 2개 World Host에서 Catch-Up을 측정
- 9. 테스트와 실측은 서로 다른 실패를 잡는다
- 정리하며
- 참고 자료
들어가며
앞선 글에서는 IOCP completion이 Runtime Session Actor를 거쳐 application frame으로 처리되는 과정을 살펴봤습니다. NetworkRuntime(=NrRuntime)이 packet을 안전하게 복원해도 gameplay state가 즉시 변경되는 것은 아닙니다. Server-authoritative game에서는 다음 질문에 답할 World 실행 경계가 추가로 필요합니다.
- NetworkRuntime에서 전달된 event를 어느 시점에 gameplay state에 반영할 것인가?
- tick 처리가 늦어져도 simulation 시간 간격을 일정하게 유지하려면 어떻게 해야 하는가?
- 밀린 tick을 따라잡는 동안 input과 outbound event는 어떤 의미를 가져야 하는가?
- 일시적인 지연과 처리 용량을 넘어선 지속 과부하를 어떻게 구분할 것인가?
Private Server의 World Server를 구현하면서 이 질문을 fixed-step simulation과 bounded catch-up으로 정리했습니다. 이 글에서는 일반적인 tick scheduling 문제부터 시작해, Ingress Pump와 World Coordinator, Outbound Publisher가 하나의 authoritative tick을 처리하는 흐름을 살펴봅니다.
이 글에서 다루는 내용:
- 완료 시각 기반 schedule에서 tick drift가 발생하는 이유
- fixed delta와 예정된 deadline으로 World 시간축을 유지하는 방법
- 밀린 tick을 건너뛰지 않으면서 한 번의 catch-up 크기를 제한하는 방법
- catch-up batch에서 ingress, 단계별 commit과 outbound가 처리되는 순서
- replaceable state와 durable event를 구분하는 기준
- tick duration, start lag와 backlog를 분리해 관측하는 이유
사전 지식: game loop,
std::chrono의 duration과 time point, server-authoritative simulation에 대한 기본 이해를 전제로 합니다.
1. NetworkRuntime 다음에는 누가 시간을 결정하는가
NetworkRuntime의 책임은 socket I/O, packet framing과 Runtime Session lifetime입니다. 정상적으로 복원된 gameplay packet은 Session Actor가 NrToWorldEvent로 만들어 To-World Queue에 집어넣고, World Server에서 이 이벤트를 받아 게임의 상태를 업데이트하게 됩니다.
그러면 World Server는 이 이벤트를 기다리고 있다가 들어올 때마다 처리를하면 될까요?
Session Actor가 보장하는 범위
Session Actor는 같은 session의 I/O state를 직렬화 해줍니다. 하지만 서로 다른 Session Actor는 여러 Worker에서 동시에 실행될 수 있습니다.
SessionActor A ── session A의 I/O state 직렬화
SessionActor B ── session B의 I/O state 직렬화
SessionActor C ── session C의 I/O state 직렬화
따라서 World Server에서 이벤트가 들어올 때마다 처리를 하는 구조라면 Actor 스케줄링 정책, Worker에서 Actor를 통해 작업을 처리하는 순서와 속도 등에 의해 게임 플레이의 순서가 결정될 수 있습니다.
여러 Session에서 공유해야 하는 상태
또한 플레이어가 입력한 명령에는 하나의 session에서 시작하더라도 여러 클라이언트에게 영향을 주는 경우가 있습니다.
- 두 플레이어가 같은 resource를 획득하려는 경우
- 플레이어가 다른 플레이어와 충돌하는 경우
- AOI 변경에 따른 replication 생성
이러한 상태는 Session Actor가 직접 변경하기도 어렵고, 설령 내부에 이러한 로직을 처리할 수 있게 하더라도, Actor 내부 실행 흐름은 단일 thread로 처리되기 때문에 여러 session에 영향을 주는 데이터에 대한 순서를 보장할 수 없게 됩니다.
Event Handoff 경계
그래서 Session Actor는 packet의 내용을 직접 해석해 게임의 상태를 변경하지 않고, 이를 ToWorldEvent로 만들어 World Server에 전달하게 됩니다.
따라서 이 경계는 단순히 NetworkRuntime과 World Server의 프로젝트의 구분이나 함수 호출을 위한 경계가 아니라 Session 단위의 직렬화와 World 단위의 게임 로직 처리를 구분하기 위한 경계라고 볼 수 있습니다.
전체적인 실행 흐름은 다음과 같습니다.
flowchart TD
Client["Game Client"]
subgraph Runtime["NrRuntime"]
direction TB
RuntimeRecv["Session Actor
Receive / Frame Decode"]
Queue["To-World Queue"]
Gateway["NrGateway
Send Submission"]
RuntimeSend["Session Actor
Send Queue / IOCP"]
RuntimeRecv --> Queue
Gateway --> RuntimeSend
end
subgraph World["World Server"]
direction TB
Pump["Ingress Pump"]
Ingress["Sealed Ingress Batch"]
Coordinator["World Coordinator
Tick / Epoch / Deadline"]
Outbound["Sealed Outbound Batch"]
Publisher["Outbound Publisher"]
Pump --> Ingress
Ingress --> Coordinator
Coordinator --> Outbound
Outbound --> Publisher
end
Client --> RuntimeRecv
Queue --> Pump
Publisher --> Gateway
RuntimeSend --> Client
World Server의 결과는 Outbound Publisher / NrGateway를 통해 다시 클라이언트에게 전달됩니다.
게임 플레이 결과를 담은 semantic payload만 World Server에서 결정하고, I/O를 통한 전송은 다시 NetworkRuntime의 책임으로 넘어가게 됩니다.
그래서 이러한 경계 구조를 두면 다음과 같은 내용을 보장할 수 있게 됩니다.
- 여러 session의 event를 World의 입력으로 모음
- 정해진 tick과 phase 안에서 동일한 검증 및 게임 로직을 적용
- World Coordinator가 state 변경 순서를 일관되게 관리
- 모든 클라이언트는 World에서 처리한 동일한 결과를 기준으로 tick 마다 새로운 상태를 받게 됨
- 여러 클라이언트가 공유하는 공유 상태를 World에서 일관되게 관리
- tick 안에서는 동일한 규칙으로 일관된 판정
다만 다음 문제를 해결하지는 않습니다.
- 클라이언트 별 네트워크 레이턴시 차이
- 패킷이 서버에 도착하는 순서
- Actor가 To World Event를 Queue에 집어넣은 순서
- tick의 경계 직전/직후에 도착한 packet의 보정 처리
- A의 명령이 tick 마감 직전에 들어오고, B의 명령이 직후에 들어오면 이 두 명령은 서로 다른 tick에서 처리될 수 있습니다. World 는 이 미세한 차이에 대해 같은 tick으로 보정해주지 않습니다.
2. Tick을 결정하는 기준
10Hz World Server를 예로 들면 fixed step은 100ms입니다.(1초에 10번의 처리)
그리고 현재 tick의 완료 시간을 기준으로 다음 tick을 계산하는 구조는 다음과 같습니다.
Tick 1 시작: 100ms
Tick 1 처리: 20ms
다음 deadline = 완료 시각 120ms + fixed step 100ms
Tick 2 시작: 220ms
Tick 2 처리: 20ms
다음 deadline = 완료 시각 240ms + fixed step 100ms
Tick 3 시작: 340ms
10Hz로 설정을 했으니 하나의 tick을 처리할 때 걸리는 예상 시간은 100ms이지만, 완료 시간을 기준으로 인터벌을 두니 실제 두 번째 tick의 시작 시간은 처음 시작 시간에서 인터벌과 처리 시간을 더한 시간으로 결정됩니다. 매 tick의 처리 시간이 다음 주기에 더해지기 때문입니다.
이를 식으로 표현하면 다음과 같습니다.
- : 다음 tick의 deadline
- : 현재 tick이 실제로 완료된 시각
- : fixed step
처리 시간이 매번 일정하더라도 완료 시각 이 계속 뒤로 이동하므로 실제 흘러가는 시간과 server tick 사이의 차이가 누적됩니다.
그래서 시작 시간을 기준으로 인터벌을 두어 World Server 내부에 일관된 평가, 처리 기준으로 사용할 수 있도록 설정을 했습니다.
시작 시간을 기준으로 두면 매 tick 마다 시뮬레이션에 필요한 delta time을 항상 일관된 수치를 사용할 수 있고, tick 사이의 처리 시간 또한 일정하게 평가할 수 있게 됩니다.
따라서 World Server에는 두 계약이 모두 필요합니다.
- 각 authoritative tick은 동일한 fixed delta로 simulation합니다.
- 다음 deadline은 실제 완료 시각이 아니라 기존에 예정된 시간축에서 fixed delta를 더한 시간을 사용합니다.
3. 예정된 Deadline을 기준으로 Tick Schedule을 유지한다
simulation에 같은 fixed delta를 전달하는 것과 별도로, 다음 deadline을 이전 deadline에서 전진시킵니다.
deadline을 별도로 두는 이유는 tick 내부 simulation 처리에 필요한 시간이 tick과 tick 사이의 기준 시간보다 오래 걸리는 경우, 몇 tick을 catch-up 할지 결정하기 위한 계산의 기준이 필요하기 때문입니다.
10Hz라면 예정된 deadline은 처리 시간과 관계없이 다음 순서를 유지합니다.
Tick 1 deadline: 100ms
Tick 2 deadline: 200ms
Tick 3 deadline: 300ms
Tick 4 deadline: 400ms
Tick 1이 120ms에 끝나도 Tick 2 deadline은 220ms가 아니라 200ms입니다. Tick 1의 20ms 처리 시간은 다음 주기에 더해지지 않습니다.
Tick 1 deadline: 100ms
- Tick 1 처리에 350ms 소모
- Tick 1 완료 시간 : 450ms
이렇게 특정 Tick의 처리 시간이 fixed delta에서 주어진 시간보다 오래 걸린다면
- 다음 정규 tick의 시작 시간은 500ms 이고
- 처리하지 못한 200ms, 300ms, 400ms의 simulation을 500ms에서 함께 처리합니다.
- 자세한 내용은 4절에서 더 설명합니다.
현재 구현의 fixedStep은 simulation에 전달할 fixed delta이면서 tick schedule을 전진시키는 간격입니다. 같은 값을 두 계약에 사용하지만, simulation의 변화량과 deadline 계산은 구분해서 다룹니다.
WorldFixedStepSchedule은 monotonic clock인 std::chrono::steady_clock을 사용합니다. Wall clock 보정으로 시간이 뒤로 이동하지 않는 clock에서 simulation deadline의 선후 관계를 비교합니다.
schedule.fixedStep_ = fixedStep;
schedule.nextDeadline_ = startedAt + fixedStep;
// batch 처리로 여러 tick을 처리한 경우(처리한 tick의 개수 = stepCount)
// tick을 실제로 stepCount 만큼 내부에서 처리하여 tick 전진이 됨
// deadline도 이에 맞춰 전진
schedule.nextDeadline_ +=
fixedStep_ * static_cast<Clock::duration::rep>(stepCount);WorldFixedStepSchedule.cpp
3.1 실행 시간과 Simulation 시간은 다르다
Fixed-step World에서 serverTick은 simulation의 논리 시간입니다. 실제 함수가 실행된 wall-clock 시각과 일치할 필요는 없습니다.
Wall-clock time: 100ms 200ms 300ms 400ms
Server tick: 100 101 102 103
Simulation delta: 0.1s 0.1s 0.1s 0.1s
일시적인 지연으로 Tick 101과 102가 320ms에 연속 실행되어도 각 tick에는 0.1초의 fixed delta가 전달됩니다. 두 tick을 0.2초짜리 큰 fixed delta 하나로 합치지 않습니다.
이 구분은 movement, collision, cooldown과 round rule이 실행 환경의 순간적인 지연 때문에 서로 다른 delta를 받지 않게 합니다.
4. 늦어진 Fixed-Step을 Bounded Catch-Up으로 처리
현재 시각이 다음 deadline보다 늦으면 실행해야 할 tick이 한 개 이상 쌓입니다. 가장 먼저 due tick 수를 계산합니다.
- 가 1450, 가 100, 이 1100이면
- due tick은 4가 됩니다.
- 처리하지 못한 1200, 1300, 1400과
- 예정된 1500에서의 처리가 포함되기 때문입니다.
그다음 한 번의 owner iteration에서 처리할 수를 제한합니다.
Private Server의 현재 serving baseline은 60Hz이며 maxCatchUpSteps는 4입니다. 따라서 World Coordinator는 한 batch에서 최대 네 개의 tick만 연속 처리합니다.
4.1 선택할 수 있는 정책
| 정책 | 장점 | 잃는 것 |
|---|---|---|
| 완료 시각으로 deadline 재설정 | backlog가 남지 않음 | 장기적인 tick cadence |
| 밀린 server tick 건너뛰기 | 빠르게 wall-clock을 따라감 | simulation tick과 gameplay event 의미 |
| 여러 tick을 큰 variable delta로 병합 | 함수 호출 횟수가 줄어듦 | fixed-step physics와 rule의 입력 조건 |
| 밀린 tick을 무제한 처리 | backlog를 빠르게 소비할 수 있음 | owner loop의 bounded 실행 시간 |
| bounded catch-up | tick 순서와 fixed delta를 보존하면서 batch 크기를 제한 | 지속 과부하에서는 backlog가 남음 |
Private Server는 마지막 정책을 선택했습니다. maxCatchUpSteps보다 많은 tick이 밀려 있으면 나머지를 버리지 않고 다음 iteration의 backlog로 남깁니다.
WorldFixedStepSchedule은 이 정책을 다음과 같이 구현합니다.
const Clock::duration overdue = now - nextDeadline_;
const std::uint64_t dueStepCount =
static_cast<std::uint64_t>(overdue / fixedStep_) + 1;
const std::uint64_t stepCount =
std::min<std::uint64_t>(dueStepCount, maxCatchUpSteps_);WorldFixedStepSchedule.cpp
4.2 Bounded Catch-Up은 과부하 해결책이 아니다
Catch-up 제한은 한 batch가 끝없이 길어지는 것을 막습니다. 하지만 한 tick을 처리하는 평균 시간이 fixed step보다 길다면 처리할수록 더 많은 tick이 밀립니다. Bounded catch-up만으로 처리 용량 부족을 해결할 수는 없습니다.
그래서 현재 구현은 지속 overrun에서 임의로 fixed delta를 늘리거나 server tick을 버리지 않습니다. Backlog와 연속 overrun을 metric으로 노출하고, 자동 감속이나 controlled stop 임계치는 실제 workload evidence가 생긴 뒤 결정하도록 남겼습니다.
5. World Coordinator가 시간축을 소유
Double-buffered 실행에서는 Pump, Coordinator와 Publisher가 서로 다른 worker thread에서 동작합니다. Worker가 여러 개라고 해서 tick 시간축도 여러 곳에서 변경하면 epoch와 buffer ownership을 함께 맞추기 어렵습니다.
Private Server에서는 WorldDoubleBufferedTickCoordinator 하나가 다음 값을 소유합니다.
- 다음 ingress/outbound 작업을 식별하는
epoch - 다음에 실행할
serverTick - Pump가 ingress slot을 seal할 예정된
sealDeadline - 마지막으로 완료된
serverTick - catch-up과 backlog 관측값
sequenceDiagram
participant C as World Coordinator
participant I as Ingress Pump
participant W as World Phase
participant O as Outbound Publisher
C->>I: plan(epoch, scheduled deadline)
I->>I: To-World event drain
I-->>C: sealed ingress completion(epoch)
C->>C: due tick / catch-up 계산
loop 최대 maxCatchUpSteps
C->>W: fixed delta tick 처리
W-->>C: typed result
C->>C: 단계별 canonical commit
end
C->>O: sealed outbound publication(epoch)
O-->>C: publication completion(epoch)
C->>C: deadline += fixedStep * processedTickCount
Ingress completion과 outbound completion은 worker 간 작업 barrier입니다. Completion이 도착했다고 임의의 새 deadline을 만들지 않습니다. Coordinator는 실제로 완료한 tick 수만큼 기존에 예정된 deadline을 전진시킵니다.
- 여기에서 말하는 completion은 IOCP의 completion이 아니라 각 Ingress/Outbound Worker의 작업 완료를 나타내는 증명입니다.
nextPlan_.serverTick += processedTickCount;
nextPlan_.sealDeadline +=
config_.fixedStep * processedTickCount;WorldDoubleBufferedTickCoordinator.h
이 단일 변경 권한 덕분에 worker notification timing과 authoritative server tick을 분리할 수 있습니다.
6. 하나의 Catch-Up Batch 처리
Catch-up batch는 단순히 ProcessTick을 여러 번 호출하는 loop가 아닙니다. Input을 어느 tick에 귀속하고, 각 tick의 mutation과 outbound를 어떤 순서로 확정할지도 함께 정의해야 합니다.
World Server의 한 batch는 다음 순서를 사용합니다.
1. 같은 epoch의 sealed ingress read slot 획득
2. ingress event를 순서대로 한 번 소비
3. due tick 수와 bounded step count 계산
4. 각 server tick:
- immutable tick input / read view 구성
- movement와 physics 계산
- movement 결과를 canonical entity state에 commit
- gameplay typed result 계산
- resource, score, round와 lifecycle 결과 commit
- durable outbound event 기록
5. 모든 catch-up tick 성공:
- 마지막 committed tick 기준 state snapshot 계획
6. outbound batch seal
7. ingress read slot release
8. tick, epoch와 예정된 deadline 전진
6.1 Ingress Event는 Batch에서 한 번 소비
같은 sealed ingress batch를 catch-up tick마다 반복해서 읽으면 하나의 command가 여러 번 적용될 수 있습니다. Coordinator는 catch-up tick loop에 들어가기 전에 ingress event를 한 번만 소비합니다.
for (const psnr::runtime::NrToWorldEvent& event : readBatch.events)
{
static_cast<void>(
eventConsumer.Handle(event, WorldInboundMode::DoubleBuffered));
}
for (std::uint32_t offset = 0; offset < stepCount; ++offset)
{
const std::uint32_t serverTick =
nextPlan_.serverTick + offset;
// fixed-step simulation과 단계별 commit
}WorldDoubleBufferedTickCoordinator.h
Ingress event를 소비한 뒤 확정된 session set과 command state를 각 tick의 input builder가 사용합니다. 하나의 ingress batch에 같은 session의 movement command가 여러 개 있다면 기존 admission 정책에 따라 마지막 유효 command가 현재 command state에 남습니다.
6.2 Catch-Up은 Client Input Replay가 아니다
Catch-up의 목적은 server simulation 시간축 복구입니다. 과거 client input을 각 catch-up tick으로 다시 분배하는 replay 기능이 아닙니다.
현재 정책은 다음 동작을 추가하지 않습니다.
- packet의
targetServerTick을 기준으로 과거 tick에 command를 재배치 - 입력이 없는 catch-up tick에 이전 movement command를 새로 합성
- 하나의 movement packet을 모든 catch-up tick에서 반복 적용
6.3 실패한 Tick 뒤에는 다음 Tick을 실행하지 않는다
Catch-up batch의 두 번째 tick에서 simulation이나 gameplay 처리에 실패했다면 세 번째 tick을 계속 실행하지 않습니다. 완료되지 않은 tick을 건너뛰고 뒤의 server tick을 commit하면 authoritative sequence에 구멍이 생기기 때문입니다.
현재 Coordinator는 첫 실패에서 loop를 중단하고 worker에 실패를 반환합니다. 이미 완료한 앞선 tick 전체를 rollback하는 transaction은 제공하지 않으므로, 각 phase의 commit 경계와 실패 가능 지점을 명시적으로 유지해야 합니다.
따라서 현재 catch-up을 정리하면 패킷 처리량을 늘리는 기능이 아니라, 밀린 simulation tick을 처리하기 위한 정책이라고 볼 수 있습니다.
하나의 catch-up batch 에서는 다음과 같이 작업이 실행됩니다.
Ingress read buffer 획득
-> event/packet N개를 한 번만 consume
-> command/session state 확정
-> Tick 100 simulation
-> Tick 101 simulation
-> Tick 102 simulation
-> Tick 103 simulation
따라서 batch로 네 tick을 처리를 하더라도 다음과 같은 상태를 가지게 됩니다.
- simulation/gameplay tick 계산만 4회 진행되고
- event consume, drain 등은 1회
- packet 개수는 ingress batch에 들어있던 N개 그대로
그래서 batch가 길어지게 되는 경우, World Server에서 클라이언트가 전송한 패킷을 실제 게임 로직에 반영하는 시점이 뒤로 밀리 수 있고, 클라이언트 입장에서는 서버의 레이턴시를 체감할 가능성이 발생합니다.
maxCatchUpSteps는 하나의 batch가 무제한으로 길어지는 것을 막습니다. 또한 여러 tick을 batch로 묶어 처리하면 ingress handoff와 snapshot publication을 매 tick 반복하지 않으므로, 같은 수의 tick을 각각 정상 경로로 처리하는 것보다 짧은 wall-clock 시간에 simulation을 전진시킬 수 있습니다. 단, simulation 자체가 대부분의 비용을 차지하거나 지속 과부하로 deadline을 회복하지 못하면 이 이점만으로 ingress 대기 시간을 해소할 수 없습니다.
정상 tick N회:
N × (
ingress plan/wait/seal
+ ingress event consume
+ simulation/gameplay
+ snapshot 계획
+ outbound seal/publication
)
catch-up batch에서 N tick:
ingress plan/wait/seal 1회
+ ingress event consume 1회
+ N × simulation/gameplay
+ tick별 durable event 기록
+ 최종 snapshot 계획 1회
+ outbound seal/publication 1회
6.4 batch 처리 중에 도착한 클라이언트의 명령 처리
클라이언트가 전송하는 명령에는 단조 증가하는 inputSequence가 있습니다. World Server에서는 이 sequence 값을 확인해 클라이언트의 최신 상태를 갱신하게 됩니다. 물론 이 최신 상태는 다음 sequence가 도착하기 전까지만 유효합니다.
정상 실행에서는 client command가 다음 ingress epoch에 포함되고, Coordinator가 해당 epoch를 소비한 뒤 simulation에 반영됩니다. 하지만 ingress slot이 seal된 뒤 catch-up batch가 실행되는 동안 도착한 command는 현재 batch 중간에 끼어들지 않습니다. 이 command는 NrRuntime의 To-World Queue에서 다음 ingress plan을 기다립니다.
정해진 시간 내로 tick simulation이 처리된다면 클라이언트의 명령은 항상 다음 ingress epoch에 포함되어 바로 그 다음 tick의 simulation에 반영이 됩니다.
하지만 catch-up batch가 실행되는 경우에는 여러 tick이 처리되고 있더라도 한 번 sealed 처리된 ingress 에서 패킷을 읽지 않고, 그 내부에서 처리를 하게 됩니다.
그리고 중간에 도착한 패킷은 To-WOrld Queue에서 대기를 하게 됩니다.
sequenceDiagram
participant Client as Game Client
participant Runtime as NrRuntime
participant Pump as Ingress Pump
participant World as World Coordinator
Client->>Runtime: inputSequence N / control state
Runtime->>Runtime: To-World Queue에 event 보관
Note over World: catch-up batch에서 Tick 100~102 처리
Note over Runtime,Pump: batch 중 도착한 event는 다음 ingress까지 대기
loop 밀린 simulation tick
World->>World: 기존 control state로 fixed-step simulation
end
World->>World: deadline을 처리한 tick 수만큼 전진
World->>Pump: 다음 ingress epoch plan
Pump->>Runtime: 대기 중인 event drain
Pump-->>World: sealed ingress batch
World->>World: sequence 검증 후 최신 control state 확정
World->>World: 다음 server tick부터 새 state 반영
예를 들어 Tick 99 처리에서 지연이 발생하고, 그 사이 같은 클클라이언트의 패킷 100, 101, 102가 도착했다고 가정합니다. 여기서 숫자는 적용할 server tick이 아니라 클라이언트 패킷의 입력 순서입니다.
Epoch 9
마지막으로 확정된 control state: command 99
Tick 99 처리 중 지연 발생
Epoch 10
Tick 100, 101, 102 catch-up
-> command 99의 control state를 각 fixed-step에서 계속 사용
-> batch 실행 중 도착한 command 100, 101, 102는 queue에서 대기
Epoch 11
command 100, 101, 102를 ingress로 drain
-> inputSequence 순서 검증
-> 같은 entity의 control state를 차례대로 갱신
-> simulation 전 최종 상태는 command 102
-> Tick 103부터 command 102의 state 사용
따라서 패킷 100, 101, 102를 Tick 100, 101, 102에 하나씩 늦게 replay하지 않습니다. 같은 ingress 경계에서 연속된 control state가 들어오면 마지막 유효 state가 남고, 이미 소비한 ingress batch의 event도 다음 epoch로 다시 복사하지 않습니다. 이 때문에 한 번의 catch-up이 이후 모든 command를 한 batch씩 영구적으로 미루는 FIFO backlog로 남지 않습니다.
이 정책이 우선하는 것은 모든 client 입력 이력을 재현하는 것보다 authoritative simulation tick이 absolute deadline을 다시 따라잡는 것입니다. Catch-up batch가 세 tick의 simulation 시간을 한 tick 주기보다 짧은 wall-clock 시간에 처리하면 deadline과 현재 시각의 차이가 줄어듭니다. deadline을 회복한 다음 epoch부터는 ingress가 다시 정상 cadence로 열리고, 새 command도 평상시의 handoff 지연으로 돌아갑니다.
대신 다음 trade-off가 있습니다.
- catch-up batch 중 도착한 패킷은 현재 batch가 끝날 때까지 반영되지 않으므로, 남은 batch 실행 시간과 다음 ingress handoff만큼 일시적인 latency가 증가합니다.
- 오래된 패킷은 늦게라도 실행되지 않고, 같은 ingress에 포함된 최신 패킷으로 갱신됩니다.
- 지속 과부하로 다음 deadline이 계속 과거에 남으면 ingress 대기가 여러 batch에 걸쳐 길어질 수 있습니다. 정상으로의 복귀는 batch 처리 비용이 밀린 simulation 시간보다 작아 schedule을 회복할 수 있다는 조건에서 성립합니다.
결과적으로 이 흐름은 모든 패킷의 실행을 보장하는 queue가 아니라, 클라이언트의 최신 요구를 보존하면서 server simulation 시간축의 복구를 우선하는 정책입니다.
Catch-up batch로 인한 일시 지연에서는 중간 패킷을 축약해 입력 backlog가 누적되는 것을 막고, 지속 과부하는 latency와 backlog metric으로 별도로 드러냅니다.
7. Replaceable State와 Durable Event를 분리
Catch-up tick을 모두 simulation한 뒤 모든 중간 snapshot을 client에게 보내면 일시적인 server 지연이 network burst로 이어질 수 있습니다. 반대로 모든 outbound를 마지막 상태 하나로 합치면 gameplay 사건이 사라질 수 있습니다.
이를 구분하기 위해 outbound를 두 성격으로 나눴습니다.
| 분류 | 예시 | Catch-up 정책 |
|---|---|---|
| Replaceable state | controlled entity 위치, remote replica state, overview snapshot | 마지막 committed tick의 최신 상태로 합침 |
| Durable event | spawn, despawn, resource 획득, death, round 전환, lifecycle 결과 | 생성된 server tick 순서대로 보존 |
예를 들어 네 tick을 catch-up했다면 entity 위치 snapshot은 Tick 103의 최종 상태 한 번만 발행할 수 있습니다.
Catch-up simulation:
Tick 100 -> position state A
Tick 101 -> position state B
Tick 102 -> position state C
Tick 103 -> position state D
State publication:
Tick 103 -> position state D 한 번 발행
하지만 Tick 101에서 resource를 획득하고 Tick 102에서 entity가 사망했다면 두 사건을 마지막 위치 D로 대체할 수 없습니다.
Durable publication order:
Tick 101 -> ResourceAcquired
Tick 102 -> EntityDeath
Tick 103 -> FinalStateSnapshot
Coordinator는 각 tick이 완료될 때 durable record를 먼저 기록합니다. Catch-up batch의 모든 tick이 성공한 경우에만 마지막 tick 범위로 state snapshot을 한 번 계획합니다.
eventConsumer.RecordDurableTickOutbound(
serverTick, batchLastServerTick, committedJoinedSessions);
// 모든 catch-up tick이 성공한 뒤 한 번 호출합니다.
eventConsumer.PublishControlledEntityStates(
nextPlan_.serverTick,
batchLastServerTick,
committedJoinedSessions);WorldDoubleBufferedTickCoordinator.h
8. 100명 × 2개 World Host에서 Catch-Up을 측정
“Tick이 느리다”는 표현에는 서로 다른 문제가 섞일 수 있습니다.
| 관측값 | 질문 |
|---|---|
| Execution duration | 한 coordinator batch의 실제 실행 시간이 얼마나 걸렸는가? |
| Start lag | 예정 deadline보다 얼마나 늦게 시작했는가? |
| Due tick count | 시작 시점에 몇 tick이 실행 대상이었는가? |
| Processed tick count | 이번 batch에서 몇 tick을 실제로 처리했는가? |
| Current backlog | 이번 batch 뒤에도 몇 tick이 남았는가? |
Execution duration이 fixed step을 넘으면 execution overrun입니다. 하지만 execution이 길었다고 반드시 catch-up이 발생한 것은 아닙니다. 반대로 execution이 짧더라도 이전 scheduling 지연 때문에 여러 tick이 due 상태가 될 수 있습니다. 이 차이를 실제 workload에서 확인하기 위해 baseline을 측정했습니다.
8.1 측정 조건
하드웨어 및 실행 환경은 다음과 같습니다.
| 항목 | 값 |
|---|---|
| CPU | Intel Core Ultra 5 226V, 8 cores / 8 logical processors |
| OS | Windows 11 IoT Enterprise LTSC, build 26100 |
| World Host | 독립 process 2개, 127.0.0.1:27015과 127.0.0.1:27016 |
| Client | Host마다 actual NrClient 100개, 총 200개 |
| Admission | Host마다 초당 20 client |
| Tick / round | 60Hz, 10,800 tick, 약 180초 |
| Catch-up 상한 | 한 coordinator batch에서 최대 4 tick |
| Control workload | client마다 500ms 간격, boost 확률 10% |
| 반복 | Host process를 새로 시작하는 독립 run 3회 |
두 controller는 각 반복에서 World Host 두 개를 동시에 실행했습니다. Client와 server가 모두 같은 머신의 localhost에서 실행되므로, 이 측정은 외부 network latency가 아니라 같은 머신에서 두 World Host가 CPU와 memory를 공유할 때의 fixed-step 실행을 확인하기 위한 참고 자료로 사용해야 합니다.
8.2 P99는 Tick Budget 안에 있지만 순간 Overrun은 발생한다
Percentile은 각 run의 running phase 전체 sample에 nearest-rank를 적용했습니다. 60Hz의 한 tick budget은 약 16.67ms입니다.
| 반복 | Host | Execution p99 | Execution max | Overrun ratio | Start lag p99 | Catch-up batch | Max due / backlog |
|---|---|---|---|---|---|---|---|
| 1 | 1 | 9.898ms | 25.688ms | 0.037% | 16.335ms | 49 | 2 / 0 |
| 1 | 2 | 9.777ms | 32.933ms | 0.084% | 16.400ms | 59 | 2 / 0 |
| 2 | 1 | 9.552ms | 29.970ms | 0.037% | 16.532ms | 78 | 2 / 0 |
| 2 | 2 | 9.657ms | 22.590ms | 0.037% | 16.507ms | 74 | 2 / 0 |
| 3 | 1 | 9.214ms | 32.104ms | 0.019% | 16.404ms | 53 | 2 / 0 |
| 3 | 2 | 9.447ms | 22.292ms | 0.009% | 16.431ms | 63 | 2 / 0 |
Execution p99는 여섯 run 모두 9.214~9.898ms로 한 tick budget 안에 있었습니다. 하지만 execution max는 22.292~32.933ms였으므로 순간 overrun까지 없었던 것은 아닙니다. 각 run의 가장 긴 execution sample 여섯 개를 확인하면 다섯 개는 processedTickCount=1이었습니다. 따라서 execution max만으로 catch-up 발생 여부를 판단할 수 없습니다.
Start lag p99는 16.335~16.532ms, max는 23.076~32.419ms였습니다. Start lag은 simulation 계산 시간만이 아니라 OS scheduling, worker wake-up과 직전 작업의 영향을 포함하므로 execution duration과 별도로 해석해야 합니다.
8.3 발생한 Catch-Up은 모두 Two-Step 안에서 끝난다
각 run에서 두 tick 이상을 처리한 catch-up batch는 49~78회였습니다. 모든 catch-up sample의 dueTickCount와 processedTickCount는 2였고, 설정한 maxCatchUpSteps=4에 도달한 batch는 없었습니다.
여섯 run은 모두 다음 correctness 조건을 만족했습니다.
- 각 Host가 server tick
10,800개를 순서대로 완료했습니다. - 요청, 입장과 round 완료 client가 모두
100/100/100이었습니다. - Client error와 Runtime pressure transaction은 0이었습니다.
- Tick sample drop과 collection failure는 0이었습니다.
- 모든 batch에서
dueTickCount - processedTickCount가 0이므로 다음 iteration에 backlog를 남기지 않았습니다.
Host process의 평균 CPU는 8.55~9.22%, peak working set은 52.03~53.18MiB였습니다. 이 값은 workload를 재현할 때의 보조 환경 정보이며, client controller나 운영체제를 포함한 머신 전체 사용량은 아닙니다.
이 baseline은 현재 머신과 workload에서 bounded catch-up이 지연된 tick을 건너뛰지 않고 회복했다는 근거입니다. 최대 수용 인원, Host 수 증가에 따른 선형 확장이나 외부 network 환경의 latency에 대한 측정 지표로는 사용할 수 없습니다. 지속 overrun에서 자동으로 tick을 버리거나 process를 종료할 threshold도 이 세 번의 측정만으로 정하지 않았습니다.
9. 테스트와 실측은 서로 다른 실패를 잡는다
실측에서 backlog가 0이었다고 해서 모든 scheduling interleaving이 안전하다고 증명되지는 않습니다. 반대로 scripted clock 테스트가 통과해도 실제 OS scheduling과 gameplay workload에서 tick budget을 지킬 수 있는지는 알 수 없습니다. Private Server는 두 검증을 분리합니다.
| 검증 수단 | 확인하는 내용 |
|---|---|
| Scripted clock 테스트 | Deadline 전진, tick 연속성, catch-up 상한과 event ordering |
| Fleet baseline | 실제 IOCP client, gameplay와 동시 Host 실행에서 execution, start lag와 backlog |
9.1 예정된 Deadline이 Drift하지 않는다
TakesSequentialTicksAtMonotonicDeadlines는 10Hz schedule에서 다음 동작을 확인합니다.
startedAt: 0ms
first deadline: 100ms
now = 100ms
-> Tick 50 한 개 획득
-> next deadline = 200ms
now = 300ms
-> Tick 51, 52 두 개 획득
-> next deadline = 400ms
두 번째 batch를 300ms에 처리해도 deadline은 300ms + 100ms로 새로 만든 값이 아닙니다. 기존 200ms deadline에서 실제로 넘긴 두 step만큼 전진한 결과입니다.
9.2 Catch-Up 제한 뒤에도 Tick을 건너뛰지 않는다
BoundsCatchUpWithoutSkippingOverdueTicks는 due tick이 다섯 개이고 최대 catch-up 수가 세 개인 상황을 사용합니다.
첫 호출: Tick 100, 101, 102
두 번째 호출: Tick 103, 104
첫 호출의 overrun은 true이지만 남은 server tick을 버리지 않습니다. 같은 현재 시각으로 다시 호출하면 연속된 다음 tick부터 backlog를 반환합니다.
9.3 Coordinator Flow를 함께 검증한다
Schedule 계산 외에도 Coordinator와 event consumer 테스트에서 다음 계약을 확인합니다.
ConsumesOneEpochAndAdvancesAbsoluteDeadline: tick 처리 시간이 있어도 deadline을 이전 예정 시각에서 전진합니다.ProcessesAtMostConfiguredCatchUpSteps: ingress event는 한 번 소비하고 네 tick의 gameplay와 durable record를 순서대로 처리합니다.StopsCatchUpAfterFirstFailedTick: 실패한 tick 뒤의 catch-up tick을 실행하지 않습니다.PreservesDurableGameplayOrderBeforeFinalCatchUpSnapshot: durable gameplay event 뒤에 마지막 catch-up snapshot을 발행합니다.
이 테스트들은 특정 wall-clock 속도나 benchmark 결과를 주장하지 않습니다. 대신 schedule, tick sequence, event ordering과 publication policy가 코드 변경 뒤에도 유지되는지 확인합니다.
정리하며
Fixed-step World Server에서 중요한 것은 simulation 함수에 일정한 delta를 전달하는 것만이 아닙니다. 어떤 실행 주체가 server tick과 deadline을 소유하고, 지연된 tick과 그 과정에서 발생한 event를 어떻게 처리하는지까지 하나의 계약으로 정의해야 합니다.
Private Server에서는 World Coordinator가 예정된 deadline과 authoritative mutation 순서를 소유합니다. 밀린 tick은 fixed delta로 순서대로 처리하지만 한 batch의 수는 제한합니다. Catch-up 도중 만들어진 중간 state snapshot은 마지막 committed tick으로 합치고, gameplay와 lifecycle event는 server tick 순서를 유지합니다.
핵심 요약:
- 완료 시각에 fixed step을 더하면 tick 처리 시간이 cadence에 누적됩니다.
- Fixed-step simulation은 같은 delta를 사용하고, tick schedule은 이전 예정 deadline을 기준으로 전진합니다.
- Bounded catch-up은 tick을 건너뛰지 않으면서 한 owner iteration의 처리 수를 제한합니다.
- World Coordinator만 tick, epoch, deadline과 authoritative commit 순서를 변경합니다.
- Catch-up은 server simulation 시간축 복구이며 client input replay가 아닙니다.
- Replaceable state는 마지막 committed tick으로 합칠 수 있지만 durable event는 순서를 보존해야 합니다.
- Tick duration, start lag와 backlog는 서로 다른 과부하 신호입니다.
- Bounded catch-up은 지속적인 처리 용량 부족 자체를 해결하지 않으므로 metric과 workload evidence가 필요합니다.
참고 자료
- Fix Your Timestep! — Fixed timestep과 simulation이 처리 시간을 따라잡지 못할 때 발생하는 spiral of death를 설명합니다.
std::chrono::steady_clock— 단조 증가하는 C++ clock의 계약을 확인합니다.- WorldFixedStepSchedule.cpp — 예정된 deadline과 bounded due tick 계산 구현입니다.
- WorldDoubleBufferedTickCoordinator.h — Ingress consume, catch-up simulation, 단계별 commit과 outbound 계획을 조정하는 구현입니다.
- WorldFixedStepScheduleTests.cpp — Monotonic deadline과 backlog-preserving catch-up 테스트입니다.
- WorldDoubleBufferedTickCoordinatorTests.cpp — Coordinator의 예정 deadline과 bounded multi-tick 처리 테스트입니다.
이 게시물은 학습한 내용을 바탕으로 초안을 작성한 뒤, LLM의 도움을 받아 내용을 검수하고 다듬어 완성되었습니다.