リアルタイムの状態同期: ネットコードのロールバックと調整
ゲーム内で敵を撃つところを想像してみてください。弾が発射され、敵は死んでいるように見えます。 突然、数メートル離れたところに生きたまま「再出現」します。これは典型的な症状です 不適切に設計されたネットコード。マルチプレイヤー ゲームでは、各クライアントにはわずかに異なるバージョンが表示されます ネットワークの遅延により段階的に調整されます。
マルチプレイヤー同期の根本的な問題は、ビジョンをどのように維持するかということです。 レイテンシが異なる数十の接続されたクライアント間でゲーム世界の一貫性を実現します。 知覚できるほどの入力ラグがあり、誰にも不正行為を許可しないでしょうか?答えはと呼ばれます ネットコードをロールバックする と組み合わせた クライアント側の予測 e サーバー調整。これら 3 つのメカニズムが連携して、 信頼性の低いネットワーク上で応答性が高く、一貫したプレイが行われているという錯覚。
何を学ぶか
- ネットコード アーキテクチャ: ロックステップ、ロールバック、ハイブリッド状態同期
- クライアント側の予測: クライアントがサーバーをどのように予測するか
- サーバーの調整: クライアントのステータスを修正する
- ネットコードのロールバック: 入力の遡及的な再シミュレーション
- エンティティ補間: 遅延にもかかわらずスムーズな動き
- デルタ圧縮による状態帯域幅の削減
ネットコードの 3 つのアーキテクチャ
| 建築 | 機構 | プロ | に対して | で使用されます |
|---|---|---|---|---|
| ロックステップ | すべてのクライアントは、先に進む前に他の全員の入力を待ちます | 完全な一貫性、保証された決定性 | 入力ラグ = レイテンシが高く、切断されやすい | クラシック 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 レイテンシーの 60 FPS ゲームでは、 クライアントは、約 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);
}
}
リモート プレーヤーのエンティティ補間
予測はローカル プレーヤーに対して機能します。リモート プレーヤーの場合は、別の手法が使用されます。 のエンティティ補間。リモートプレーヤーをその場所にレンダリングする代わりに 「現在」(サーバーから受信した位置、常に遅い)、クライアントはそれらをレンダリングします。 受信した最後の 2 つのスナップショットの間を補間して、わずかに過去の位置に移動します。
// 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 人のプレイヤーが 1 秒あたり 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: オープンマッチとナカマ - オープンソースのゲームバックエンド







