Tantangan State Synchronization pada Multi-Worker Engine

Membangun custom engine deterministik untuk game cepat seperti DOOM menuntut pemrosesan komputasi fisik, logika entitas, dan rendering terisolasi tanpa latensi tak terprediksi. Saat beban komputasi dipecah ke beberapa worker thread atau node terdistribusi, sistem rentan mengalami state drift dan race condition. Masalah ini muncul jika dua worker memperbarui mutable state yang sama secara konkuren pada tick yang belum tervalidasi.

Arsitektur runtime modern, seperti yang diadaptasi pada implementasi WebAssembly/worker Betlang DOOM, menyelesaikan masalah ini dengan memisahkan pipeline simulasi menjadi dua komponen: Worker Input Queue berurutan dan Tick Lock State Loop berbasis snapshot deterministik.

Arsitektur Sinkronisasi: Tick Lock & Worker Queue

Pola ini bergantung pada siklus diskrit (ticks). Tiap tick memproses fixed delta time (Δt), misalnya 35Hz pada standar idTech 1 (sekitar 28.57ms per tick). Worker tidak langsung mengubah representasi memori global. Alur eksekusi diatur sebagai berikut:

  • Ingestion: Input player dan event lingkungan masuk ke queue berurutan (FIFO) berlabel tick_id target.
  • Tick Lock: Engine mengunci state dunia dari tick sebelumnya (N-1) sebagai read-only snapshot. Worker thread dilarang memutasi state referensi.
  • Concurrent Execution: Worker mengeksekusi sistem independen (AI raycasting, deteksi tabrakan BSP, pembaruan proyektil) menggunakan data snapshot N-1 dan mengonsumsi event antrean tick N. Mutasi ditulis ke delta buffer terisolasi.
  • Commit / Barrier: Main thread mengumpulkan delta buffer, menyelesaikan konflik secara deterministik, menerapkan perubahan ke tick N, dan memajukan penunjuk tick global.

Implementasi Minimal: State Engine dan Worker Loop

Contoh berikut menunjukkan implementasi minimal tick lock loop dan command queue menggunakan TypeScript/JavaScript modern yang kompatibel dengan Web Workers atau Node.js worker threads.

// Definisi payload perintah dan state dunia
interface Command {
  tickId: number;
  entityId: number;
  type: 'MOVE' | 'FIRE';
  payload: { x?: number; y?: number };
}

interface EntityState {
  id: number;
  x: number;
  y: number;
  hp: number;
}

interface WorldSnapshot {
  readonly tick: number;
  readonly entities: ReadonlyMap<number, EntityState>;
}

class TickStateEngine {
  private currentTick = 0;
  private snapshot: WorldSnapshot = { tick: 0, entities: new Map() };
  private inputQueue: Command[] = [];
  private isTickLocked = false;

  public enqueueCommand(cmd: Command): void {
    // Tolak command yang terlambat melampaui tick aktif (mencegah drift)
    if (cmd.tickId < this.currentTick) {
      return;
    }
    this.inputQueue.push(cmd);
  }

  public step(workerProcessors: Array<(snap: WorldSnapshot, cmds: Command[]) => EntityState[]>): WorldSnapshot {
    // 1. Kunci tick aktif untuk isolasi pembacaan state
    this.isTickLocked = true;
    const nextTick = this.currentTick + 1;
    
    // 2. Ambil perintah untuk tick yang akan dieksekusi
    const tickCommands = this.inputQueue.filter(cmd => cmd.tickId === nextTick);
    this.inputQueue = this.inputQueue.filter(cmd => cmd.tickId > nextTick);

    // 3. Eksekusi worker paralel secara terisolasi (membaca snapshot read-only)
    const nextEntities = new Map<number, EntityState>(this.snapshot.entities);
    
    for (const processor of workerProcessors) {
      const updates = processor(this.snapshot, tickCommands);
      for (const update of updates) {
        // Terapkan mutasi ke buffer state baru
        nextEntities.set(update.id, update);
      }
    }

    // 4. Komit perubahan dan buka kunci (Snapshot swap)
    this.snapshot = {
      tick: nextTick,
      entities: nextEntities
    };
    this.currentTick = nextTick;
    this.isTickLocked = false;

    return this.snapshot;
  }
}

// ponytail: SharedArrayBuffer dan SIMD dilewati, gunakan TypedArrays jika throughput > 10k entitas
const engine = new TickStateEngine();
const mockWorker = (snap: WorldSnapshot, cmds: Command[]): EntityState[] => {
  return cmds.map(cmd => {
    const current = snap.entities.get(cmd.entityId) || { id: cmd.entityId, x: 0, y: 0, hp: 100 };
    return {
      ...current,
      x: current.x + (cmd.payload.x || 0),
      y: current.y + (cmd.payload.y || 0)
    };
  });
};

engine.enqueueCommand({ tickId: 1, entityId: 42, type: 'MOVE', payload: { x: 5, y: -2 } });
const updatedState = engine.step([mockWorker]);
console.assert(updatedState.entities.get(42)?.x === 5, 'Tick state sync failed');

Mitigasi Race Condition dan Konsistensi Drift

1. Fixed-Point Arithmetic vs Floating Point Non-Determinism

Floating-point pada CPU yang berbeda menghasilkan variasi presisi IEEE 754 mikro tergantung compiler optimization flag. Gunakan representasi fixed-point integer (16.16 atau 32.32) untuk vector math dan orientasi sudut DOOM (seperti tipe data fixed_t bawaan DOOM asli) untuk memastikan determinisme bit-for-bit di seluruh thread.

2. Late-Arriving Event Discard & Client Rollback

Ketika worker terdistribusi mengirim input yang tertinggal dari tick komit lokal (tick event < tick sekarang), jangan mutasi state aktif secara in-place. Dua strategi yang dapat diambil:

  • Lockstep Hard Drop: Buang input yang datang terlambat jika target sistem mengutamakan determinisme kaku pada dedicated tick synchronization (misalnya co-op campaign).
  • Rewind & Re-simulate: Rollback state ke snapshot N-k, masukkan input yang tertinggal ke antrean pada tick yang sesuai, lalu eksekusi ulang tick N-k hingga N secara headless tanpa rendering overhead.

3. Ring Buffer Mengurangi Allocation Churn

Alokasi memory dinamis per-tick memicu garbage collection pause yang mematikan real-time loop. Gantikan alokasi objek dinamis dengan pre-allocated circular ring buffer pada input queue dan SharedArrayBuffer untuk segmentasi worker data.

Debugging dan Verifikasi Determinisme

Untuk menguji integritas sinkronisasi antar-worker, hitung checksum SHA-256 atau CRC32 dari seluruh state buffer terkomit pada akhir setiap tick. Jalankan simulasi paralel pada dua worker pool berbeda dengan sequence input yang identik. Jika terjadi disparitas hash antar-worker pada tick tertentu, terjadi desynchronization bug pada pembacaan referensi atau pengurutan order-dependent operations.