실시간 상태 동기화: 넷코드 롤백 및 조정
게임에서 적을 쏘는 것을 상상해 보십시오. 총알이 터지고 적이 죽는 것처럼 보이다가 갑자기 몇 미터 떨어진 곳에 살아서 "다시 나타납니다". 이는 전형적인 A형 증상이다. 잘못 설계된 넷코드. 멀티플레이어 게임에서 각 클라이언트는 약간 다른 버전을 봅니다. 네트워크 지연으로 인해 시간이 단계적으로 적용됩니다.
멀티플레이어 동기화의 근본적인 문제는 비전을 유지하는 방법입니다. 도입하지 않고도 지연 시간이 서로 다른 수십 개의 연결된 클라이언트 간에 게임 세계의 일관성을 유지합니다. 인지 가능한 입력 지연이 있고 누구도 부정 행위를 허용하지 않습니까? 대답은 롤백 넷코드 와 결합 클라이언트 측 예측 e 서버 조정. 이 세 가지 메커니즘이 함께 작동하여 생성됩니다. 신뢰할 수 없는 네트워크에서 반응성이 뛰어나고 일관된 플레이를 할 수 있다는 환상입니다.
무엇을 배울 것인가
- 넷코드 아키텍처: 잠금 단계, 롤백, 하이브리드 상태 동기화
- 클라이언트 측 예측: 클라이언트가 서버를 예상하는 방법
- 서버 조정: 클라이언트 상태 수정
- 롤백 넷코드: 입력의 소급 재시뮬레이션
- 엔터티 보간: 대기 시간에도 불구하고 부드러운 움직임
- 상태 대역폭을 줄이기 위한 델타 압축
넷코드의 세 가지 아키텍처
| 건축학 | 기구 | 찬성 | 에 맞서 | 다음에서 사용됨 |
|---|---|---|---|---|
| 록스텝 | 모든 클라이언트는 진행하기 전에 다른 사람의 입력을 기다립니다. | 완벽한 일관성, 보장된 결정성 | 입력 지연 = 대기 시간이 길고 연결 끊김에 취약함 | 클래식 RTS(에이지 오브 엠파이어, 스타크래프트) |
| 롤백 | 예측된 입력으로 진행하고, 틀리면 뒤로 돌아가세요. | 인지할 수 있는 입력 지연 없음, 반응성 | 시각적 깜박임, CPU 집약적 | 격투 게임(스트리트 파이터, 길티 기어) |
| 상태 동기화 + 예측 | 권위있는 서버, 클라이언트가 예측하고 조정합니다. | 확장 가능하고 안전하며 균형 잡혀 있음 | 구현이 더 복잡함 | FPS/TPS(CS2, 오버워치, 발로란트) |
클라이언트 측 예측
La 클라이언트 측 예측 클라이언트가 즉시 실행하는 기술 서버의 확인을 기다리지 않고 로컬로 입력합니다. 이로 인해 입력 지연이 제거됩니다. 인지 가능: "앞으로"를 누르면 캐릭터가 즉시 화면을 가로질러 이동합니다.
비결은 클라이언트가 서버로 전송된 모든 입력의 기록을 유지하고 식별된다는 것입니다. 일련번호로. 서버가 응답하면 클라이언트는 서버의 권한 있는 위치를 사용하여 자신의 예측 위치를 확인합니다.
// Client-side prediction con storico input
interface PlayerInput {
sequence: number; // Numero progressivo di ogni input
timestamp: number;
moveX: number; // -1, 0, 1
moveY: number;
actions: string[]; // 'jump', 'shoot', 'crouch'
deltaTime: number; // Tempo dall'ultimo input
}
interface PlayerState {
x: number;
y: number;
velocityX: number;
velocityY: number;
health: number;
sequence: number; // Ultimo input applicato
}
class ClientPrediction {
private pendingInputs: PlayerInput[] = []; // Input inviati ma non ancora confermati
private predictedState: PlayerState;
private lastConfirmedSequence = 0;
constructor(initialState: PlayerState) {
this.predictedState = { ...initialState };
}
// Processa un nuovo input del giocatore locale
processInput(input: PlayerInput): void {
// 1. Applica immediatamente l'input alla predizione locale
this.predictedState = this.simulatePhysics(this.predictedState, input);
// 2. Aggiungi allo storico degli input pendenti
this.pendingInputs.push(input);
// 3. Invia al server (non-blocking, fire-and-forget)
this.network.sendInput(input);
// 4. Aggiorna il rendering con la posizione predetta
this.renderer.setPlayerPosition(this.predictedState.x, this.predictedState.y);
}
// Riceve lo stato autoritativo dal server
onServerState(serverState: PlayerState): void {
// 1. Scarta gli input precedenti alla conferma del server
this.pendingInputs = this.pendingInputs.filter(
input => input.sequence > serverState.sequence
);
// 2. Controlla se c'è divergenza significativa
const dx = Math.abs(this.predictedState.x - serverState.x);
const dy = Math.abs(this.predictedState.y - serverState.y);
const CORRECTION_THRESHOLD = 0.5; // Unita di gioco
if (dx > CORRECTION_THRESHOLD || dy > CORRECTION_THRESHOLD) {
// 3. Server reconciliation: riparti dallo stato server
this.predictedState = { ...serverState };
// 4. Riapplica tutti gli input pendenti (quelli dopo la conferma)
for (const input of this.pendingInputs) {
this.predictedState = this.simulatePhysics(this.predictedState, input);
}
// 5. Aggiorna il renderer con la nuova posizione corretta
this.renderer.setPlayerPosition(this.predictedState.x, this.predictedState.y);
}
this.lastConfirmedSequence = serverState.sequence;
}
// Simulazione deterministica della fisica (deve essere identica client e server)
private simulatePhysics(state: PlayerState, input: PlayerInput): PlayerState {
const SPEED = 200; // px/sec
const FRICTION = 0.85;
const newVelX = (state.velocityX + input.moveX * SPEED * input.deltaTime) * FRICTION;
const newVelY = (state.velocityY + input.moveY * SPEED * input.deltaTime) * FRICTION;
return {
...state,
x: state.x + newVelX * input.deltaTime,
y: state.y + newVelY * input.deltaTime,
velocityX: newVelX,
velocityY: newVelY,
sequence: input.sequence
};
}
}
롤백 넷코드
롤백은 단순한 예측과 다릅니다. 예측은 로컬 플레이어에 관한 반면, 롤백은 모든 원격 플레이어의 입력을 처리합니다. 200ms 지연 시간의 60FPS 게임에서 클라이언트는 약 12프레임 동안 원격 플레이어의 입력을 알지 못합니다. 기다리는 대신 (추가 입력 지연) 또는 마지막으로 알려진 입력을 사용하거나(결함 발생) 롤백을 통해 원격 입력을 예측합니다. 시뮬레이션을 진행한 다음 예측이 틀렸으면 돌아가서 다시 시뮬레이션합니다.
// Rollback netcode - gestione input remoti
interface GameFrame {
frameNumber: number;
states: Map<string, PlayerState>; // playerId -> state
inputs: Map<string, PlayerInput>; // playerId -> input di quel frame
}
class RollbackManager {
private frameHistory: GameFrame[] = [];
private readonly MAX_HISTORY = 60; // 1 secondo a 60fps
private currentFrame = 0;
// Salva lo stato di ogni frame (necessario per il rollback)
saveFrame(states: Map<string, PlayerState>, inputs: Map<string, PlayerInput>): void {
const frame: GameFrame = {
frameNumber: this.currentFrame++,
states: new Map(states),
inputs: new Map(inputs)
};
this.frameHistory.push(frame);
// Mantieni solo gli ultimi MAX_HISTORY frame
if (this.frameHistory.length > this.MAX_HISTORY) {
this.frameHistory.shift();
}
}
// Riceve l'input di un giocatore remoto con ritardo
onRemoteInput(playerId: string, input: PlayerInput): { needsRollback: boolean; fromFrame: number } {
const targetFrame = this.frameHistory.find(f => f.frameNumber === input.sequence);
if (!targetFrame) {
// Input troppo vecchio, ignoriamo
return { needsRollback: false, fromFrame: -1 };
}
const predictedInput = targetFrame.inputs.get(playerId);
const inputChanged = !this.inputsEqual(predictedInput, input);
if (inputChanged) {
// L'input reale differisce dalla predizione: serve rollback
targetFrame.inputs.set(playerId, input);
return { needsRollback: true, fromFrame: input.sequence };
}
return { needsRollback: false, fromFrame: -1 };
}
// Esegue il rollback: risimula dalla frame indicata
rollback(fromFrame: number, simulate: (state: Map<string, PlayerState>, inputs: Map<string, PlayerInput>) => Map<string, PlayerState>): Map<string, PlayerState> {
const startIdx = this.frameHistory.findIndex(f => f.frameNumber === fromFrame);
if (startIdx === -1) throw new Error(`Frame ${fromFrame} non trovata in history`);
// Parte dallo stato del frame precedente a quello errato
let currentStates = this.frameHistory[startIdx].states;
// Risimula tutti i frame fino al corrente con gli input corretti
for (let i = startIdx; i < this.frameHistory.length; i++) {
const frame = this.frameHistory[i];
currentStates = simulate(currentStates, frame.inputs);
frame.states = new Map(currentStates); // Aggiorna la history
}
return currentStates;
}
// Predice l'input di un giocatore remoto basandosi sull'ultimo noto
predictRemoteInput(playerId: string): PlayerInput {
// Strategia comune: ripeti l'ultimo input conosciuto
for (let i = this.frameHistory.length - 1; i >= 0; i--) {
const input = this.frameHistory[i].inputs.get(playerId);
if (input) return { ...input, sequence: this.currentFrame };
}
// Default: input neutro (nessun movimento)
return { sequence: this.currentFrame, timestamp: Date.now(), moveX: 0, moveY: 0, actions: [], deltaTime: 1/60 };
}
private inputsEqual(a: PlayerInput | undefined, b: PlayerInput): boolean {
if (!a) return false;
return a.moveX === b.moveX && a.moveY === b.moveY &&
JSON.stringify(a.actions) === JSON.stringify(b.actions);
}
}
원격 플레이어를 위한 엔터티 보간
예측은 로컬 플레이어에게 적용됩니다. 원격 플레이어의 경우 다른 기술이 사용됩니다. 는엔터티 보간. 원격 플레이어를 해당 위치로 렌더링하는 대신 "현재"(서버에서 수신된 위치이며 항상 늦음) 클라이언트는 이를 렌더링합니다. 수신된 마지막 두 스냅샷 사이를 보간하여 약간 과거 위치로 이동합니다.
// Entity interpolation per giocatori remoti
interface Snapshot {
timestamp: number;
positions: Map<string, { x: number; y: number; rotation: number }>;
}
class EntityInterpolation {
private snapshots: Snapshot[] = [];
private readonly INTERPOLATION_DELAY_MS = 100; // Renderizziamo 100ms nel passato
private readonly MAX_SNAPSHOTS = 20;
// Riceve un nuovo snapshot dal server
onSnapshot(snapshot: Snapshot): void {
this.snapshots.push(snapshot);
this.snapshots.sort((a, b) => a.timestamp - b.timestamp);
if (this.snapshots.length > this.MAX_SNAPSHOTS) {
this.snapshots.shift();
}
}
// Calcola la posizione interpolata di ogni entità al tempo corrente
getInterpolatedPositions(): Map<string, { x: number; y: number; rotation: number }> {
// Renderizziamo nel passato per avere sempre due snapshot tra cui interpolare
const renderTime = Date.now() - this.INTERPOLATION_DELAY_MS;
// Trova i due snapshot che racchiudono il renderTime
let before: Snapshot | null = null;
let after: Snapshot | null = null;
for (const snap of this.snapshots) {
if (snap.timestamp <= renderTime) before = snap;
if (snap.timestamp >= renderTime && !after) after = snap;
}
if (!before || !after) return new Map();
// Interpola tra i due snapshot
const t = (renderTime - before.timestamp) / (after.timestamp - before.timestamp);
const result = new Map<string, { x: number; y: number; rotation: number }>();
for (const [playerId, posAfter] of after.positions) {
const posBefore = before.positions.get(playerId);
if (!posBefore) continue;
result.set(playerId, {
x: this.lerp(posBefore.x, posAfter.x, t),
y: this.lerp(posBefore.y, posAfter.y, t),
rotation: this.lerpAngle(posBefore.rotation, posAfter.rotation, t)
});
}
return result;
}
private lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
// Interpolazione angolare: gestisce il wrap-around 0-360
private lerpAngle(a: number, b: number, t: number): number {
let diff = b - a;
if (diff > 180) diff -= 360;
if (diff < -180) diff += 360;
return a + diff * t;
}
}
대역폭을 줄이기 위한 델타 압축
64명의 플레이어가 초당 60번 상태를 업데이트하는 게임에서 전체 상태를 보냅니다. 모든 진드기는 비실용적입니다. 거기 델타 압축 비교된 차이점만 보냅니다. 클라이언트가 확인한 마지막 상태로.
// Delta compression per snapshot di gioco
interface EntityState {
id: string;
x: number;
y: number;
health: number;
animation: string;
sequence: number;
}
class DeltaCompressor {
private lastAcknowledgedState = new Map<string, EntityState>();
// Crea un delta tra lo stato corrente e quello confermato dal client
createDelta(
currentStates: Map<string, EntityState>,
clientAckSequence: number
): { changed: EntityState[]; removed: string[]; baseline: number } {
const changed: EntityState[] = [];
const removed: string[] = [];
for (const [id, current] of currentStates) {
const last = this.lastAcknowledgedState.get(id);
if (!last || this.hasChanged(last, current)) {
changed.push(current);
}
}
// Entità rimosse (non presenti nello stato corrente)
for (const [id] of this.lastAcknowledgedState) {
if (!currentStates.has(id)) {
removed.push(id);
}
}
return { changed, removed, baseline: clientAckSequence };
}
// Applica un delta lato client
applyDelta(
currentStates: Map<string, EntityState>,
delta: { changed: EntityState[]; removed: string[] }
): Map<string, EntityState> {
const newStates = new Map(currentStates);
for (const entity of delta.changed) {
newStates.set(entity.id, entity);
}
for (const id of delta.removed) {
newStates.delete(id);
}
return newStates;
}
private hasChanged(prev: EntityState, curr: EntityState): boolean {
// Tolleranza per floating point
const EPSILON = 0.01;
return (
Math.abs(prev.x - curr.x) > EPSILON ||
Math.abs(prev.y - curr.y) > EPSILON ||
prev.health !== curr.health ||
prev.animation !== curr.animation
);
}
}
성능 비교: 동기화 접근 방식
| 기술 | 필요한 대역폭 | 클라이언트 CPU | 시각적 입력 지연 | 난이도 암시. |
|---|---|---|---|---|
| 예측 없음 | 낮은 | 낮은 | = 네트워크 대기 시간 | 쉬운 |
| 고객 예측 + 화해 | 평균 | 평균 | ~0ms 인식됨 | 평균 |
| 전체 넷코드 롤백 | 평균 | 높음(재시뮬레이션) | 0ms 보장 | 높은 |
| 델타 압축 + 보간 | 매우 낮음(-60-80%) | 평균 | ~100ms(지연) | 중간-높음 |
결정성 요구 사항
롤백은 시뮬레이션이 결정적인 경우에만 작동합니다. 즉, 동일한 입력이 동일한 경우에 제공됩니다.
주문하면 정확히 동일한 출력이 생성됩니다. 이는 다음의 사용을 제외합니다: Math.random()
서로 다른 아키텍처 간의 시딩되지 않은 비결정적 부동 소수점 연산 및
외부 엔트로피의 근원. 항상 고정 시드 의사 난수 생성기를 사용하세요.
게임 논리.
동기화 모범 사례
- 고정 시간 단계 사용: 프레임 속도에 관계없이 고정된 단계(예: 64Hz)에서 시뮬레이션을 업데이트합니다.
- 입력 버퍼: 대기 시간 변화(지터)를 흡수하기 위해 입력 버퍼를 유지합니다.
- 지연 보상: FPS 적중의 경우 서버 측 되감기를 적용하여 클라이언트 타임라인의 적중을 평가합니다.
- 한도 내역: 롤백에 필요한 프레임만 유지(최대 대기 시간/프레임 시간)
- 모니터 차이: 수정이 발생하는 시기와 빈도를 추적하는 측정항목을 추가하세요.
결론
멀티플레이어 게임의 상태 동기화는 다음과 같은 네트워크 엔지니어링 문제입니다. 더 복잡합니다. 클라이언트 측 예측, 서버 조정 및 엔터티 보간의 조합 최신 FPS 및 TPS를 위한 표준 솔루션이며 롤백 넷코드는 대체할 수 없습니다. 모든 프레임이 중요한 격투 게임에 적합합니다.
다음 기사에서는 이에 대해 다루겠습니다.치트 방지 아키텍처: 안전하게 만드는 방법 권위있는 서버와 행동 분석을 통해 비정상적인 행동을 탐지하는 방법.
다음 게임 백엔드 시리즈
- 기사 05: 치트 방지 아키텍처 및 행동 분석
- 기사 06: 오픈 매치와 나카마 - 오픈 소스 게임 백엔드







