Memindahkan game loop arcade berbasis touch canvas ke framework Server-Side Rendering (SSR) seperti Next.js atau Nuxt sering kali memicu error hydration mismatch. Masalah ini muncul karena server mencoba merender state awal elemen <canvas> tanpa memiliki akses ke objek runtime browser seperti window, navigator, atau screen context.

Akar Masalah Hydration Error pada Game Canvas

Hydration error pada komponen game loop umumnya dipicu oleh tiga faktor utama:

  • Akses Dimensi dan Resolusi Layar Prematur: Server tidak memiliki nilai window.devicePixelRatio atau window.innerWidth. Jika atribut width dan height pada tag <canvas> dihitung saat kompilasi SSR menggunakan fallback (misalnya default 300x150), nilainya akan bertabrakan dengan kalkulasi client yang langsung membaca ukuran display perangkat.
  • Attachment Touch Event Listener Sebelum Mount: Menjalankan fungsi pendaftaran event sentuh (seperti canvas.ontouchstart atau addEventListener('touchstart')) di luar lifecycle client-side akan gagal saat render SSR atau menimbulkan node DOM tidak identik.
  • Game Loop Timer Berjalan di Server: Pemanggilan loop render berbasis requestAnimationFrame atau setInterval pada scope inisialisasi modul dapat menyebabkan exception Node.js (requestAnimationFrame is not defined) atau desinkronisasi state frame pertama.

Solusi 1: Defer Initialization ke Lifecycle Client

Cara paling sederhana menjaga sinkronisasi DOM adalah memisahkan deklarasi elemen markup dari inisialisasi game engine. Gunakan hook lifecycle yang hanya dieksekusi di browser (useEffect di React atau onMounted di Vue).

import { useEffect, useRef } from 'react';

export function TouchCanvasGame() {
  const canvasRef = useRef(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const ctx = canvas.getContext('2d');
    const dpr = window.devicePixelRatio || 1;
    const rect = canvas.getBoundingClientRect();

    // Kalibrasi resolusi canvas terhadap DPI layar
    canvas.width = rect.width * dpr;
    canvas.height = rect.height * dpr;
    ctx.scale(dpr, dpr);

    // Event listener touch-first
    const handleTouch = (e) => {
      e.preventDefault();
      const touch = e.touches[0];
      const x = touch.clientX - rect.left;
      const y = touch.clientY - rect.top;
      // Proses koordinat input game
    };

    canvas.addEventListener('touchstart', handleTouch, { passive: false });

    // Game loop execution
    let animationId;
    const loop = () => {
      // Logic update & render
      animationId = requestAnimationFrame(loop);
    };
    animationId = requestAnimationFrame(loop);

    return () => {
      cancelAnimationFrame(animationId);
      canvas.removeEventListener('touchstart', handleTouch);
    };
  }, []);

  return (
    <div className="canvas-wrapper">
      <canvas 
        ref={canvasRef} 
        style={{ width: '100%', height: '100%', display: 'block' }}
      />
    </div>
  );
}

Solusi 2: Client-Only Component dengan Dynamic Import

Jika engine game bergantung penuh pada modul browser eksternal (misalnya library physics atau audio Web API), cara paling bersih adalah mengecualikan seluruh komponen dari tahap SSR menggunakan dynamic import.

Contoh pada Next.js:

import dynamic from 'next/dynamic';

const CanvasGameNoSSR = dynamic(
  () => import('../components/CanvasGame').then((mod) => mod.CanvasGame),
  { 
    ssr: false,
    loading: () => <div className="canvas-placeholder" />
  }
);

export default function GamePage() {
  return (
    <main>
      <CanvasGameNoSSR />
    </main>
  );
}

Mencegah Cumulative Layout Shift (CLS) pada Placeholder

Menonaktifkan SSR atau menunda render canvas sering memicu pergeseran tata letak (Cumulative Layout Shift) saat canvas selesai dimount. Atasi masalah ini dengan menetapkan rasio aspek deterministik pada wrapper menggunakan CSS modern.

.canvas-wrapper,
.canvas-placeholder {
  width: 100%;
  max-width: 600px;
  aspect-ratio: 9 / 16; /* Menjaga aspek rasio game arcade mobile */
  background-color: #111;
  position: relative;
  touch-action: none; /* Mencegah default scrolling browser saat input sentuh */
}

Atribut touch-action: none pada wrapper sangat penting untuk game loop touch arcade agar browser tidak memicu interupsi scrolling atau zoom saat input swipe dieksekusi secara intensif.