// ============================================================
// Memory Maze — App (main state machine)
// ============================================================

const { useState, useEffect, useRef, useCallback } = window.React;

// Toast helper — global
const toastHost = () => document.getElementById('toast-host');
window.toast = (msg, variant = '', duration = 2200) => {
  const host = toastHost();
  if (!host) return;
  const el = document.createElement('div');
  el.className = `toast ${variant}`;
  el.textContent = msg;
  host.appendChild(el);
  setTimeout(() => { el.style.opacity = '0'; el.style.transform = 'translateY(-10px)'; }, duration - 220);
  setTimeout(() => el.remove(), duration);
};

// Modal state (imperative). modalResolver lets us short-circuit action handlers
// that don't have the React state in scope (keyboard handlers, raw callbacks).
// __setModalOpen is the React-state mirror so we can visually disable buttons
// while a modal is up.
let modalResolver = null;
const showModal = (opts) => new Promise((resolve) => {
  modalResolver = resolve;
  window.__setModal?.(opts);
  window.__setModalOpen?.(true);
});
const closeModal = (val) => {
  window.__setModal?.(null);
  window.__setModalOpen?.(false);
  const r = modalResolver; modalResolver = null;
  r?.(val);
};

// Format a breakdown value as "+N", "−N", or "0" so chips read like a tally.
const fmtBreakdownVal = (v) =>
  v > 0 ? `+${v}` : v < 0 ? `−${Math.abs(v)}` : '0';

const Modal = () => {
  const [opts, setOpts] = useState(null);
  useEffect(() => { window.__setModal = setOpts; }, []);
  if (!opts) return null;
  // No backdrop-to-dismiss — every modal here is a decision (confirm/cancel)
  // where misclicks have real consequences (Cash out, Leave match, etc.), so
  // the user must hit a button explicitly.
  return (
    <div className="modal-backdrop show">
      <div className="modal">
        <h3 className={`h2 modal-title${opts.tone ? ` tone-${opts.tone}` : ''}`}>{opts.title}</h3>
        {opts.breakdown && (
          <div className="modal-breakdown">
            {opts.breakdown.map((row, i) => (
              <div key={i} className={`mb-row ${row.kind || ''}`}>
                <span className="mb-val">{fmtBreakdownVal(row.value)}</span>
                <span className="mb-label">{row.label}</span>
              </div>
            ))}
            {opts.total != null && (
              <div className="mb-total">
                <span className="mb-val">{fmtBreakdownVal(opts.total)}</span>
                <span className="mb-label">total</span>
              </div>
            )}
          </div>
        )}
        {opts.message && <p className="body modal-msg">{opts.message}</p>}
        <div className="modal-actions">
          {!opts.hideCancel && (
            <button className="btn" onClick={() => closeModal(false)}>
              {opts.cancelText || 'Cancel'}
            </button>
          )}
          <button className={`btn ${opts.danger ? 'btn-danger' : 'btn-primary'}`} onClick={() => closeModal(true)}>
            {opts.confirmText || 'OK'}
          </button>
        </div>
      </div>
    </div>
  );
};

// Confetti burst
const confettiBurst = () => {
  const colors = ['#8B6FE8','#FFE28A','#FF9B8A','#A8E5CB','#FFFBF2'];
  for (let i = 0; i < 40; i++) {
    const p = document.createElement('div');
    p.className = 'confetti';
    p.style.left = `${Math.random() * 100}vw`;
    p.style.background = colors[i % colors.length];
    p.style.animationDelay = `${Math.random() * 0.6}s`;
    p.style.animationDuration = `${1.8 + Math.random() * 1.2}s`;
    document.body.appendChild(p);
    setTimeout(() => p.remove(), 3200);
  }
};

// ============================================================
// App
// ============================================================
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "direction": "balanced",
  "shadowOffset": 5,
  "accent": "violet",
  "logoFont": "bagel"
}/*EDITMODE-END*/;

// One-shot purge of legacy (pre-"Memory Maze") localStorage keys so returning
// browsers start clean. Runs at module load, once per page-load.
try {
  for (const k of ['blox-lb-solo', 'blox-lb-gauntlet', 'blox-lb-match',
                   'blox-lb-solo-v2', 'blox-lb-match-v2', 'blox-alias']) {
    localStorage.removeItem(k);
  }
} catch {}

const App = () => {
  const [tweaks, setTweak] = (window.useTweaks || ((d) => [d, () => {}]))(TWEAK_DEFAULTS);

  // Screen: splash | menu | game | results
  const [screen, setScreen] = useState('splash');

  // Mirror of modal-open state — drives the GameScreen button-disable so that
  // a focused-then-overlaid button can't fire on Space/Enter, and so backdrop-
  // covered clicks can't squeak through any z-index edge cases.
  const [modalOpen, setModalOpen] = useState(false);
  useEffect(() => { window.__setModalOpen = setModalOpen; }, []);

  const [alias, setAliasState] = useState(() => localStorage.getItem('mm-alias') || '');
  const setAlias = (v) => { setAliasState(v); localStorage.setItem('mm-alias', v); };

  const [muted, setMuted] = useState(false);

  // Practice (solo wins) and Match leaderboards stay local — they're casual
  // session counters. The Gauntlet boards are networked and live below.
  const [lbSolo, setLbSolo] = useState(() => {
    try { return JSON.parse(localStorage.getItem('mm-lb-solo-v2') || '[]'); } catch { return []; }
  });
  const recordSoloWin = (name) => {
    setLbSolo(prev => {
      const next = [...prev];
      const idx = next.findIndex(r => r.name === name);
      if (idx >= 0) next[idx] = { ...next[idx], wins: next[idx].wins + 1 };
      else next.push({ name, wins: 1 });
      next.sort((a,b) => b.wins - a.wins);
      localStorage.setItem('mm-lb-solo-v2', JSON.stringify(next));
      return next;
    });
  };

  // ── Gauntlet leaderboards (networked, per-seed) ──────────────────
  // Friends seed is a permanent fixture; weekly rotates every Monday UTC.
  // Both subscribe live to Firestore. We seed initial state from a tiny local
  // cache so the menu doesn't flash empty rows on cold load — the live data
  // overwrites the cache on first snapshot.
  const weeklyInfo = useRef(weeklySeedInfo()).current;
  const lbCacheKey = (seed) => `mm-lb-cache-${seed}`;
  const readLbCache = (seed) => {
    try { return JSON.parse(localStorage.getItem(lbCacheKey(seed)) || '[]'); } catch { return []; }
  };
  const writeLbCache = (seed, entries) => {
    try { localStorage.setItem(lbCacheKey(seed), JSON.stringify(entries)); } catch {}
  };
  const [lbFriends, setLbFriends] = useState(() => readLbCache(FRIENDS_SEED));
  const [lbWeekly, setLbWeekly] = useState(() => readLbCache(weeklyInfo.seed));

  useEffect(() => {
    if (!window.MazeNet?.subscribeLeaderboard) return;
    const unsubFriends = window.MazeNet.subscribeLeaderboard(FRIENDS_SEED, (entries) => {
      setLbFriends(entries);
      writeLbCache(FRIENDS_SEED, entries);
    });
    const unsubWeekly = window.MazeNet.subscribeLeaderboard(weeklyInfo.seed, (entries) => {
      setLbWeekly(entries);
      writeLbCache(weeklyInfo.seed, entries);
    });
    return () => { unsubFriends?.(); unsubWeekly?.(); };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const recordGauntletRun = (name, levels, points, seed) => {
    if (!window.MazeNet?.submitGauntletRun) return;
    window.MazeNet.submitGauntletRun({ seed: String(seed), name, levels, points });
  };

  const [lbMatch, setLbMatch] = useState(() => {
    try { return JSON.parse(localStorage.getItem('mm-lb-match-v2') || '[]'); } catch { return []; }
  });
  const recordMatch = (p1, p1Score, p2, p2Score, winner) => {
    setLbMatch(prev => {
      const next = [{ p1, p1Score, p2, p2Score, winner, t: Date.now() }, ...prev].slice(0, 20);
      localStorage.setItem('mm-lb-match-v2', JSON.stringify(next));
      return next;
    });
  };

  // Game state
  const [game, setGame] = useState(null);

  // Apply direction style modifiers
  useEffect(() => {
    const root = document.documentElement;
    if (tweaks.shadowOffset != null) {
      const v = tweaks.shadowOffset;
      root.style.setProperty('--shadow-sm', `${Math.max(2, v - 2)}px ${Math.max(2, v - 2)}px 0 var(--ink)`);
      root.style.setProperty('--shadow-md', `${v}px ${v}px 0 var(--ink)`);
      root.style.setProperty('--shadow-lg', `${v + 1}px ${v + 1}px 0 var(--ink)`);
      root.style.setProperty('--shadow-xl', `${v + 3}px ${v + 3}px 0 var(--ink)`);
    }
    if (tweaks.accent === 'teal') {
      root.style.setProperty('--violet', '#5FD0C2');
      root.style.setProperty('--violet-hi', '#7FE0D5');
      root.style.setProperty('--violet-lo', '#3FB0A2');
      root.style.setProperty('--violet-wash', '#D5F0EB');
    } else if (tweaks.accent === 'pink') {
      root.style.setProperty('--violet', '#F08AC8');
      root.style.setProperty('--violet-hi', '#FAA8D8');
      root.style.setProperty('--violet-lo', '#D060A0');
      root.style.setProperty('--violet-wash', '#FBDFEF');
    } else {
      root.style.setProperty('--violet', '#8B6FE8');
      root.style.setProperty('--violet-hi', '#A088F0');
      root.style.setProperty('--violet-lo', '#6F52D0');
      root.style.setProperty('--violet-wash', '#E8E0FE');
    }
    root.style.setProperty('--font-display', tweaks.logoFont === 'space' ? "'Space Grotesk', sans-serif" : "'Bagel Fat One', 'Space Grotesk', sans-serif");
    document.body.dataset.direction = tweaks.direction || 'balanced';
  }, [tweaks.shadowOffset, tweaks.accent, tweaks.logoFont, tweaks.direction]);

  // --- Mute toggle
  const toggleMute = () => {
    MazeAudio.resume();
    setMuted(m => { MazeAudio.setMuted(!m); return !m; });
  };

  // Unlock audio on first gesture
  useEffect(() => {
    const unlock = () => { MazeAudio.init(); MazeAudio.resume(); };
    window.addEventListener('pointerdown', unlock, { once: true });
    window.addEventListener('keydown',     unlock, { once: true });
    return () => {
      window.removeEventListener('pointerdown', unlock);
      window.removeEventListener('keydown', unlock);
    };
  }, []);

  // --- Nav helpers
  const goHome = () => {
    if (game?.kind === 'match' && window.MazeNet) {
      // Tell the other client we left so their match closes too
      window.MazeNet.leaveMatch({ markClosed: true });
    }
    setGame(null);
    setScreen('menu');
    MazeAudio.startBgm('menu');
  };

  // Reconnect to an in-flight match if we have a session token
  useEffect(() => {
    if (!window.MazeNet) return;
    let cancelled = false;
    (async () => {
      const result = await window.MazeNet.tryReconnect({
        onState: applyRemoteSnapshot,
        onClose: handleRoomClosed,
      });
      if (cancelled || !result) return;
      applyRemoteSnapshot(result.initial);
      setGame(g => ({ ...(g || {}), role: result.role }));
      setScreen('game');
      MazeAudio.startBgm('match');
      toast(`Reconnected to ${result.code}`, 'success', 2000);
    })();
    return () => { cancelled = true; };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const onStart = () => {
    MazeAudio.init(); MazeAudio.resume();
    MazeAudio.sfx.confirm();
    MazeAudio.startBgm('menu');
    setScreen('menu');
  };

  // ------------------------------
  // SOLO game
  // ------------------------------
  const startSolo = (profileKey, opts = {}) => {
    MazeAudio.sfx.confirm();
    const profile = DIFFICULTY_PROFILES[profileKey] || DIFFICULTY_PROFILES.medium;
    const board = generateRandomBoard(profile, opts.seed || null);
    setGame({
      kind: 'solo',
      profileKey: profile.key,
      board,
      phase: 'memorize',
      round: 1,
      deadline: Date.now() + profile.memorizeMs,
      maxWalls: profile.walls,
      p1: { name: 'Puzzle', score: 0 },
      p2: { name: alias || 'Solo', score: 0 },
      mode: 'casual',
    });
    setScreen('game');
    MazeAudio.startBgm('match');
    MazeAudio.sfx.reveal();
  };

  const startGauntlet = (seedStr) => {
    const numSeed = parseSeedInput(seedStr);
    MazeAudio.sfx.confirm();
    setGame({
      kind: 'gauntlet',
      seed: numSeed,
      seedStr,
      level: 1,
      totalLevels: GAUNTLET_LEVELS,
      totalScore: 0,
      totalStars: 0,
      totalMoves: 0,
      board: null,
      phase: 'loading',
      round: 1,
      mode: 'casual',
      p1: { name: 'Gauntlet', score: 0 },
      p2: { name: alias || 'Solo', score: 0 },
    });
    setScreen('game');
    MazeAudio.startBgm('match');
    // Load first level
    setTimeout(() => loadGauntletLevel(1, 0, numSeed), 80);
  };

  const loadGauntletLevel = (level, runningScore, numSeed) => {
    const profile = gauntletProfile(level);
    const board = generateRandomBoard(profile, numSeed * 1000 + level);
    setGame(g => ({
      ...g,
      board,
      level,
      difficulty: profile.walls,
      totalScore: runningScore,
      phase: 'memorize',
      deadline: Date.now() + profile.memorizeMs,
      maxWalls: profile.walls,
      p2: { name: alias || 'Solo', score: runningScore },
    }));
    MazeAudio.sfx.reveal();
  };

  // ------------------------------
  // Networked 2-player match — Firestore-backed via window.MazeNet
  // ------------------------------
  // Apply a snapshot from Firestore into local state. Called whenever the
  // remote doc changes. Match state is the single source of truth — local
  // setGame writes go through MazeNet.updateMatch, which round-trips here.
  // CRITICAL: keep the existing board reference when content is unchanged.
  // Otherwise every heartbeat tick produces a fresh array, which breaks
  // downstream effects that watch game.board (the queue/startTile reset
  // effect was wiping the mover's queued path on every heartbeat).
  const applyRemoteSnapshot = (snap) => {
    if (!snap) return;
    // The other player wrote phase='closed' (via leaveMatch). The doc still
    // exists, so subscribe() doesn't fire onClose — handle it here.
    if (snap.phase === 'closed') {
      if (window.MazeNet) window.MazeNet.leaveMatch({ markClosed: false });
      handleRoomClosed();
      return;
    }
    setGame(g => {
      const incomingFlat = snap.board || new Array(35).fill(0);
      const prevBoard = g?.board;
      const prevFlat = prevBoard ? window.MazeNet.boardToFlat(prevBoard) : null;
      const sameBoard = prevFlat && prevFlat.length === incomingFlat.length &&
        prevFlat.every((v, i) => v === incomingFlat[i]);
      const board = sameBoard ? prevBoard : window.MazeNet.boardFromFlat(incomingFlat);
      return {
        ...(g || {}),
        kind: 'match',
        port: snap.port,
        mode: snap.mode,
        maxWalls: snap.maxWalls,
        round: snap.round,
        phase: snap.phase,
        activeBuilder: snap.activeBuilder,
        p1: snap.p1, p2: snap.p2,
        board,
        deadline: snap.deadline,
        heartbeat_p1: snap.heartbeat_p1,
        heartbeat_p2: snap.heartbeat_p2,
        // Run-time fields broadcast by the active mover so the spectator
        // can mirror the runner dot animation.
        runQueue: snap.runQueue || null,
        runStart: snap.runStart || null,
        runId: snap.runId || null,   // bumps each time mover hits Run, kicks the spectator effect
        // Latest round breakdown (round number, mover name, score, multi-line
        // text) so the spectator's between-rounds + joiner's gameover modals
        // can show what just happened.
        lastRoundBreakdown: snap.lastRoundBreakdown || null,
        // Live wall count from the active builder. Drives the spectator's
        // build-progress meter + commentary while they wait.
        wallProgress: typeof snap.wallProgress === 'number' ? snap.wallProgress : 0,
        // role is local-only — don't overwrite
        role: g?.role,
      };
    });
  };

  const handleRoomClosed = () => {
    setGame(null);
    setScreen('menu');
    MazeAudio.startBgm('menu');
    showModal({
      title: 'Match closed',
      message: 'The other player ended the match or disconnected.',
      confirmText: 'OK', hideCancel: true,
      mascot: 'think',
    });
  };

  const startLocalMatch = async ({ mode, firewalls }) => {
    if (!window.MazeNet) {
      toast('Network layer still loading — try again in a sec.', 'warn', 2400);
      return;
    }
    if (!alias) {
      toast('Set your alias first', 'warn', 2000);
      return;
    }
    MazeAudio.sfx.confirm();
    try {
      const { code, initial } = await window.MazeNet.hostMatch({
        aliasP1: alias,
        mode,
        maxWalls: firewalls,
        onState: applyRemoteSnapshot,
        onClose: handleRoomClosed,
      });
      applyRemoteSnapshot(initial);
      setGame(g => ({ ...(g || {}), role: 'p1' }));
      setScreen('game');
      MazeAudio.startBgm('match');
      toast(`Hosting port ${code} — waiting for player`, 'success', 2400);
    } catch (e) {
      console.warn('Host failed', e);
      toast('Could not start match — try again', 'error', 2400);
    }
  };

  const joinMatch = async (code) => {
    if (!window.MazeNet) {
      toast('Network layer still loading — try again in a sec.', 'warn', 2400);
      return;
    }
    if (!alias) {
      toast('Set your alias first', 'warn', 2000);
      return;
    }
    MazeAudio.sfx.confirm();
    try {
      const { initial } = await window.MazeNet.joinMatch({
        code, aliasP2: alias,
        onState: applyRemoteSnapshot,
        onClose: handleRoomClosed,
      });
      applyRemoteSnapshot(initial);
      setGame(g => ({ ...(g || {}), role: 'p2' }));
      setScreen('game');
      MazeAudio.startBgm('match');
      toast(`Connected to ${code}`, 'success', 2000);
    } catch (e) {
      MazeAudio.sfx.invalid();
      const messages = {
        NOT_FOUND: `That code doesn't match any open room. Double-check with whoever's hosting.`,
        CLOSED: `That match is already finished.`,
        TAKEN: `That room already has a second player.`,
      };
      await showModal({
        title: e.code === 'NOT_FOUND' ? `No host at port ${code}` : 'Could not join',
        message: messages[e.code] || 'Connection failed — try again.',
        confirmText: 'OK', hideCancel: true,
        mascot: 'oof',
      });
    }
  };

  // ------------------------------

  return (
    <div className="phone-shell">
      <TopBar muted={muted} onToggleMute={toggleMute} onHome={async () => {
        // Logo doubles as a back button:
        //   game  → menu (with leave-match confirmation if mid-match)
        //   menu  → splash
        //   splash → no-op
        if (screen === 'game') {
          const ok = await showModal({
            title: 'Leave the match?',
            message: 'Your progress in this match will be lost.',
            confirmText: 'Leave', cancelText: 'Stay', danger: true, mascot: 'oof',
          });
          if (!ok) return;
          goHome();
          return;
        }
        if (screen === 'menu') {
          MazeAudio.stopBgm();
          setScreen('splash');
        }
      }} />

      {screen === 'splash' && (
        <Splash onStart={onStart} />
      )}

      {screen === 'menu' && (
        <MainMenu
          alias={alias}
          setAlias={setAlias}
          onSolo={(d) => startSolo(d)}
          onGauntlet={startGauntlet}
          onHost={startLocalMatch}
          onJoin={joinMatch}
          lbSolo={lbSolo}
          lbFriends={lbFriends}
          lbWeekly={lbWeekly}
          weeklyInfo={weeklyInfo}
          lbMatch={lbMatch}
        />
      )}

      {screen === 'game' && game && (
        <GameScreen
          game={game}
          setGame={setGame}
          modalOpen={modalOpen}
          onExit={goHome}
          onResult={(result) => {
            if (!result) return;
            if (result.kind === 'gauntlet' && result.winner) {
              // Skip dud entries — crashing on level 1 produces 0/0 which would
              // sit on the board as a fake "best" until the player beat it.
              if (result.levels > 0) {
                recordGauntletRun(result.winner, result.levels, result.points, result.seed);
              }
            } else if (result.kind === 'solo' && result.winner) {
              recordSoloWin(result.winner);
            } else if (result.kind === 'match') {
              recordMatch(result.p1, result.p1Score, result.p2, result.p2Score, result.winner);
            }
          }}
        />
      )}

      <Modal />

      {/* Only mount the tweaks panel when running inside the editor harness
          (parent frame). In a normal deploy window.parent === window. */}
      {window.TweaksPanel && window.parent !== window && (
        <TweaksPanel>
          <TweakSection label="Visual direction" />
          <TweakRadio label="Direction" value={tweaks.direction}
            options={['balanced','bold','soft']}
            onChange={(v) => setTweak('direction', v)} />
          <TweakSelect label="Accent" value={tweaks.accent}
            options={[
              {value:'violet', label:'Violet'},
              {value:'teal',   label:'Teal'},
              {value:'pink',   label:'Pink'},
            ]}
            onChange={(v) => setTweak('accent', v)} />
          <TweakSelect label="Logo typeface" value={tweaks.logoFont}
            options={[
              {value:'bagel', label:'Bagel Fat One'},
              {value:'space', label:'Space Grotesk'},
            ]}
            onChange={(v) => setTweak('logoFont', v)} />
          <TweakSection label="Chunkiness" />
          <TweakSlider label="Shadow offset" value={tweaks.shadowOffset}
            min={0} max={10} step={1} unit="px"
            onChange={(v) => setTweak('shadowOffset', v)} />
        </TweaksPanel>
      )}
    </div>
  );
};

// Spectator's build-phase entertainment block. Live wall-count meter from
// the active builder + commentary that escalates with progress.
const SpectatorBuildView = ({ wallProgress, maxWalls, builderName }) => {
  const ratio = maxWalls > 0 ? wallProgress / maxWalls : 0;
  let mood = 'idle', message = '...';
  if (maxWalls > 0) {
    if (wallProgress === 0)            { mood = 'sleep'; message = 'silence. either thinking or napping.'; }
    else if (wallProgress >= maxWalls) { mood = 'cheer'; message = 'out of walls — deploy is imminent'; }
    else if (ratio < 0.30)             { mood = 'idle';  message = 'warming up the wall budget'; }
    else if (ratio < 0.55)             { mood = 'wink';  message = 'sketching it out'; }
    else if (ratio < 0.85)             { mood = 'think'; message = 'this is starting to look mean'; }
    else                               { mood = 'oof';   message = "they're plotting your demise"; }
  }
  const fillCls = ratio === 0 ? 'bp-fill empty' : 'bp-fill';
  return (
    <div className="build-progress scene-enter">
      <div className="bp-header">
        <span className="bp-label">{builderName}'s build</span>
        <span className="bp-count">{wallProgress}/{maxWalls}</span>
      </div>
      <div className="bp-bar">
        <div className={fillCls} style={{ width: `${ratio * 100}%` }} />
      </div>
      <div className="bp-quip-row">
        <p className="bp-quip">{message}</p>
      </div>
      <p className="bp-hint">tap the board — pop balloons while you wait</p>
    </div>
  );
};

// ============================================================
// GameScreen — handles all in-match phases
// ============================================================
const GameScreen = ({ game, setGame, modalOpen, onExit, onResult }) => {
  const [overlay, setOverlay] = useState(makeEmptyBoard());
  const [workingBoard, setWorkingBoard] = useState(() => cloneBoard(game.board || makeEmptyBoard()));
  const [timeLeft, setTimeLeft] = useState(60);
  const [mascotMood, setMascotMood] = useState('idle');
  const [mascotMsg, setMascotMsg] = useState(null);

  // Auto-clear the callout ~3s after any change so it stops blocking the board.
  useEffect(() => {
    if (mascotMood === 'idle' && !mascotMsg) return;
    const id = setTimeout(() => { setMascotMood('idle'); setMascotMsg(null); }, 3000);
    return () => clearTimeout(id);
  }, [mascotMood, mascotMsg]);

  // Runner state
  const [startTile, setStartTile] = useState(null);
  const [queue, setQueue] = useState([]);
  const [runnerDot, setRunnerDot] = useState(null);
  const [executing, setExecuting] = useState(false);
  // How many queue moves the runner has actually completed. Drives the
  // trailing ghost overlay so the path lights up behind the runner instead
  // of being pre-painted across the whole queued route.
  const [runStep, setRunStep] = useState(-1);

  const boardRef = useRef(null);

  // Mirror of workingBoard for synchronous reads during a drag stroke. Multiple
  // pointerEnter events can fire before React re-renders, so reading from
  // state directly would let through stale wall-counts and path checks.
  const workingBoardRef = useRef(workingBoard);
  useEffect(() => { workingBoardRef.current = workingBoard; }, [workingBoard]);

  // Set on pointerdown to 'add' or 'remove' based on the cell's current state;
  // every pointerEnter during the same stroke uses this so a drag never
  // alternates between placing and erasing as it passes over mixed cells.
  const buildStrokeRef = useRef(null);

  // Latched true the moment the player adds a move to the queue, then consumed
  // by the auto-run effect when the resulting tail reaches the top row. Without
  // this guard, closing the post-run modal would briefly leave the previous
  // round's queue alive (with tail.y === 0) before setGame() flipped phase to
  // memorize — and the auto-run effect would fire on the stale queue and play
  // the old path on the new board.
  const autoRunArmedRef = useRef(false);

  // Sync workingBoard when game.board changes
  useEffect(() => {
    setWorkingBoard(cloneBoard(game.board || makeEmptyBoard()));
    setOverlay(makeEmptyBoard());
    setStartTile(null);
    setQueue([]);
    setRunnerDot(null);
    setRunStep(-1);
    autoRunArmedRef.current = false;
  }, [game.board, game.round, game.level, game.phase]);

  // Timer tick. `fired` latches so onTimerEnd only runs once per deadline —
  // otherwise tabbing away past the deadline causes every 200ms tick to refire
  // submitMaze, and the empty-board modal re-opens the moment you dismiss it.
  useEffect(() => {
    if (!game.deadline) return;
    let fired = false;
    const id = setInterval(() => {
      const left = Math.max(0, Math.ceil((game.deadline - Date.now()) / 1000));
      setTimeLeft(left);
      if (left === 0 && !fired) {
        fired = true;
        onTimerEnd();
      }
    }, 200);
    return () => clearInterval(id);
  }, [game.deadline, game.phase]);

  // Resets the shared match doc to a fresh round 1 so the host can keep
  // playing the same opponent without going back to the menu.
  const restartMatch = (lastP1, lastP2) => {
    const emptyBoard = makeEmptyBoard();
    const r1Deadline = Date.now() + 60000;
    const newP1 = { ...(lastP1 || game.p1), score: 0, stars: 0 };
    const newP2 = { ...(lastP2 || game.p2), score: 0, stars: 0 };
    setGame(g => ({
      ...g,
      p1: newP1, p2: newP2,
      round: 1,
      phase: 'build',
      activeBuilder: 'p1',
      board: emptyBoard,
      deadline: r1Deadline,
      runQueue: null, runStart: null, runId: null,
      lastRoundBreakdown: null,
    }));
    if (window.MazeNet) {
      window.MazeNet.updateMatch({
        p1: newP1, p2: newP2,
        round: 1,
        phase: 'build',
        activeBuilder: 'p1',
        board: window.MazeNet.boardToFlat(emptyBoard),
        deadline: r1Deadline,
        runQueue: null, runStart: null, runId: null,
        lastRoundBreakdown: null,
        wallProgress: 0,
      });
    }
    MazeAudio.sfx.confirm();
  };

  // Spectator-side breakdown modal. Fires as soon as the mover broadcasts the
  // round's breakdown — no waiting for the mover to click their own modal. The
  // mover's flow stays untouched (they get their own modal locally). For round
  // 1 end the spectator (the round-1 maker) sees stats + "they're building
  // next." For round 2 end (match end) the joiner sees stats + final tally.
  // Auto-closes if the host restarts (lastRoundBreakdown cleared) or when the
  // next round's build phase starts.
  const breakdownShownRef = useRef(0);   // round number we've already fired for
  const breakdownOpenRef = useRef(false); // is our modal currently open
  useEffect(() => {
    if (game.kind !== 'match') return;
    // Don't pop while either side's run animation is still playing.
    if (executing) return;
    const bd = game.lastRoundBreakdown;
    if (!bd) {
      if (breakdownOpenRef.current) {
        breakdownOpenRef.current = false;
        closeModal(false);
        MazeAudio.sfx.confirm();
      }
      breakdownShownRef.current = 0;
      return;
    }
    // The active mover for this round handles their own modal locally.
    const moverRoleForRound = bd.round === 1 ? 'p2' : 'p1';
    if (game.role === moverRoleForRound) return;
    if (breakdownShownRef.current === bd.round) return;
    breakdownShownRef.current = bd.round;

    const isMatchEnd = bd.round === 2;
    let modalOpts;
    if (isMatchEnd) {
      const p1s = game.p1?.score || 0;
      const p2s = game.p2?.score || 0;
      const winner = p1s > p2s ? game.p1?.name
                   : p2s > p1s ? game.p2?.name
                   : 'Draw';
      const hostName = game.p1?.name || 'Host';
      const myName = game.role === 'p1' ? game.p1?.name : game.p2?.name;
      modalOpts = {
        title: winner === 'Draw' ? "It's a tie!" : `${winner} wins!`,
        tone: winner === 'Draw' ? undefined : (winner === myName ? 'success' : 'fail'),
        breakdown: bd.items || [],
        total: bd.score,
        message:
          `Final\n` +
          `${game.p1?.name || 'P1'}: ${p1s}\n` +
          `${game.p2?.name || 'P2'}: ${p2s}\n\n` +
          `${hostName} can start a new match — or you can leave now.`,
        confirmText: 'Leave', hideCancel: true,
        mascot: winner === 'Draw' ? 'think' : 'cheer',
      };
    } else {
      const opponent = game.role === 'p1' ? (game.p2?.name || 'Player 2') : (game.p1?.name || 'Player 1');
      modalOpts = {
        title: `Round ${bd.round}: ${bd.mover} scored ${bd.score}`,
        breakdown: bd.items || [],
        total: bd.score,
        message: `${opponent} will build the next maze — get ready to run.`,
        confirmText: 'OK', hideCancel: true,
        mascot: 'wink',
      };
    }

    breakdownOpenRef.current = true;
    showModal(modalOpts).then((clicked) => {
      const wasUserClick = breakdownOpenRef.current;
      breakdownOpenRef.current = false;
      if (isMatchEnd && wasUserClick && clicked) onExit();
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [game.kind, game.role, executing, game.lastRoundBreakdown, game.p1, game.p2]);

  // Force-close any lingering breakdown modal when the next round's build
  // phase starts (spectator was lazy and didn't dismiss before the mover
  // advanced).
  useEffect(() => {
    if (game.kind !== 'match') return;
    if (breakdownOpenRef.current && game.phase === 'build') {
      breakdownOpenRef.current = false;
      closeModal(false);
    }
  }, [game.kind, game.phase, game.round]);

  const onTimerEnd = () => {
    if (game.phase === 'build') { submitMaze(); }
    else if (game.phase === 'memorize') { transitionToExecute(); }
  };

  // --- Build phase logic
  // Place a single wall, reading from workingBoardRef so drag strokes don't
  // race past their own state updates. `silent` skips toasts/invalid flashes
  // for drag — failures are common and usually self-evident from the visual.
  const tryPlaceWall = (x, y, { silent = false } = {}) => {
    const board = workingBoardRef.current;
    if (board[y][x] !== 0) return false;
    const walls = countWalls(board);
    if (walls >= game.maxWalls) {
      if (!silent) { toast('Out of walls', 'warn'); MazeAudio.sfx.invalid(); }
      return false;
    }
    const next = cloneBoard(board); next[y][x] = 1;
    if (!hasPath(next)) {
      if (!silent) {
        setOverlay(o => { const nn = cloneBoard(o); nn[y][x] = 'invalid'; return nn; });
        setTimeout(() => setOverlay(o => { const nn = cloneBoard(o); nn[y][x] = 0; return nn; }), 320);
        MazeAudio.sfx.invalid();
        toast('Leave a path to the top', 'warn');
      }
      return false;
    }
    workingBoardRef.current = next;
    setWorkingBoard(next);
    setOverlay(o => { const nn = cloneBoard(o); nn[y][x] = 'pop'; return nn; });
    setTimeout(() => setOverlay(o => { const nn = cloneBoard(o); nn[y][x] = 0; return nn; }), 280);
    MazeAudio.sfx.place();
    if (game.kind === 'match' && window.MazeNet) {
      window.MazeNet.updateMatch({ wallProgress: countWalls(next) });
    }
    return true;
  };

  const tryRemoveWall = (x, y) => {
    const board = workingBoardRef.current;
    if (board[y][x] !== 1) return false;
    const next = cloneBoard(board); next[y][x] = 0;
    workingBoardRef.current = next;
    setWorkingBoard(next);
    MazeAudio.sfx.tap();
    if (game.kind === 'match' && window.MazeNet) {
      window.MazeNet.updateMatch({ wallProgress: countWalls(next) });
    }
    return true;
  };

  const toggleWall = (x, y) => {
    if (game.phase !== 'build') return;
    const board = workingBoardRef.current;
    if (board[y][x] === 1) {
      buildStrokeRef.current = 'remove';
      tryRemoveWall(x, y);
    } else if (board[y][x] === 0) {
      buildStrokeRef.current = 'add';
      tryPlaceWall(x, y);
    }
  };

  // Friendly nudge when the player taps the board during memorize. Walls are
  // visible but inert — make it explicit so they don't think the game's broken.
  // First tap of the session fires an explanatory modal (catches newcomers);
  // after that, subsequent taps fall back to a debounced quip so the
  // modal doesn't pop up every time someone misclicks.
  const memoModalShownRef = useRef(false);
  const memoQuipIdxRef = useRef(0);
  const memoQuipAtRef = useRef(0);
  const showMemorizeTapHint = () => {
    if (!memoModalShownRef.current) {
      memoModalShownRef.current = true;
      MazeAudio.sfx.tap();
      showModal({
        title: 'Just memorize for now',
        message: 'Study the walls and diamonds. When you\'ve got a path in your head, hit "I\'m ready — hide the board" below. You\'ll run the maze blind from memory.',
        confirmText: 'Got it',
        hideCancel: true,
        mascot: 'wink',
      });
      return;
    }
    const now = Date.now();
    if (now - memoQuipAtRef.current < 1400) return;
    memoQuipAtRef.current = now;
    const quips = [
      'eyes only — burn the reds in',
      'no taps yet — just memorize',
      'save it for the run',
      "clicking doesn't help — look hard",
    ];
    const msg = quips[memoQuipIdxRef.current % quips.length];
    memoQuipIdxRef.current++;
    setMascotMood('think');
    setMascotMsg(msg);
    MazeAudio.sfx.tap();
  };

  // Spectator's idle-board fidget: tap or drag a cell to inflate-then-pop a
  // balloon there. Tracked in a ref so a fast drag doesn't spam the same cell
  // mid-animation; entries clear after the animation timeout.
  const fidgetPoppingRef = useRef(new Set());
  const popFidgetCell = (x, y) => {
    const key = `${x},${y}`;
    if (fidgetPoppingRef.current.has(key)) return;
    fidgetPoppingRef.current.add(key);
    setOverlay(o => { const nn = cloneBoard(o); nn[y][x] = 'fidget-pop'; return nn; });
    MazeAudio.sfx.popBalloon();
    spawnParticles(x, y, 'win', 6);
    setTimeout(() => {
      setOverlay(o => { const nn = cloneBoard(o); nn[y][x] = 0; return nn; });
      fidgetPoppingRef.current.delete(key);
    }, 480);
  };

  const submitMaze = () => {
    if (modalOpen || modalResolver) return;
    if (game.phase !== 'build') return;
    // In match mode, only the active builder is allowed to submit. Otherwise
    // the spectator's timer-tick would race with the builder's deploy and
    // overwrite the maze with their own (empty) workingBoard.
    if (game.kind === 'match' && game.role !== game.activeBuilder) return;
    let walls = countWalls(workingBoardRef.current);
    if (walls === 0) {
      showModal({
        title: 'Deploy an empty board?',
        message: "You haven't placed any walls. That's a free run for your opponent.",
        confirmText: 'Deploy anyway', cancelText: 'Keep building',
        mascot: 'think',
      }).then(ok => { if (ok) finalizeMaze(); });
      return;
    }
    finalizeMaze();
  };

  // Match mode used to occasionally end up with 6+ stars. The race: the build
  // timer hits 0 in the same tick the user clicks Deploy, both submitMaze
  // calls capture phase==='build' from the stale closure, both run finalize,
  // and each scatters 3 stars onto its own clone. Guard with a ref + strip
  // any pre-existing 3s so a re-entry can't compound.
  const finalizingRef = useRef(false);
  useEffect(() => {
    if (game.phase === 'build') finalizingRef.current = false;
  }, [game.phase, game.round, game.level]);

  const finalizeMaze = () => {
    if (finalizingRef.current) return;
    finalizingRef.current = true;
    // Sprinkle stars (up to 3) onto reachable non-wall cells not in top/bottom row
    const next = cloneBoard(workingBoard);
    // Defensive: strip any stale stars in case workingBoard somehow carried them
    for (let y = 0; y < ROWS; y++) for (let x = 0; x < COLS; x++) {
      if (next[y][x] === 3) next[y][x] = 0;
    }
    const empties = reachableEmptiesInterior(next);
    for (let i = 0; i < 3 && empties.length; i++) {
      const idx = Math.floor(Math.random() * empties.length);
      const pt = empties.splice(idx, 1)[0];
      next[pt.y][pt.x] = 3;
    }
    const memTime = game.mode === 'comp' ? 10000 : 60000;
    const deadline = Date.now() + memTime;
    setGame(g => ({ ...g, board: next, phase: 'memorize', deadline }));
    MazeAudio.sfx.reveal();
    setMascotMood('wink');
    setMascotMsg('Memorize!');
    if (game.kind === 'match' && window.MazeNet) {
      window.MazeNet.updateMatch({
        board: window.MazeNet.boardToFlat(next),
        phase: 'memorize',
        deadline,
      });
    }
  };

  const transitionToExecute = () => {
    if (modalOpen || modalResolver) return;
    // Only the active mover advances memorize → execute. Otherwise the
    // spectator's memorize-timer-tick races with the mover's "Ready" click.
    if (game.kind === 'match' && game.role === game.activeBuilder) return;
    setGame(g => ({ ...g, phase: 'execute', deadline: null }));
    MazeAudio.sfx.hide();
    setMascotMood('think');
    setMascotMsg('Blind mode!');
    if (game.kind === 'match' && window.MazeNet) {
      window.MazeNet.updateMatch({
        phase: 'execute', deadline: 0,
        runQueue: null, runStart: null, runId: null,
      });
    }
  };

  // --- Mover logic
  const handleCellTap = (x, y) => {
    if (modalOpen || modalResolver) return;
    if (game.kind === 'match') {
      // Spectator's idle board during the other player's build phase doubles
      // as a fidget toy — tap to pop a balloon.
      if (game.phase === 'build' && game.role !== game.activeBuilder) {
        popFidgetCell(x, y);
        return;
      }
      if ((game.phase === 'memorize' || game.phase === 'execute') && game.role === game.activeBuilder) return;
    }
    if (game.phase === 'build') { toggleWall(x, y); return; }
    if (game.phase === 'memorize') { showMemorizeTapHint(); return; }
    if (game.phase !== 'execute' || executing) return;

    // No start yet — must be bottom row
    if (!startTile) {
      if (y !== ROWS - 1) {
        MazeAudio.sfx.invalid();
        toast('Start from the bottom row', 'warn');
        return;
      }
      setStartTile({ x, y });
      setQueue([]);
      MazeAudio.sfx.place();
      return;
    }

    // Re-pick start
    if (queue.length === 0 && y === ROWS - 1 && x !== startTile.x) {
      setStartTile({ x, y });
      MazeAudio.sfx.place();
      return;
    }

    const tail = computeTail(startTile, queue);

    // Tapping the tail cell is a no-op — undo is only via the Undo button,
    // so an accidental tap on the path tip can't retract progress.
    if (queue.length > 0 && x === tail.x && y === tail.y) return;

    // Tap adjacent to tail = queue next move
    const dx = x - tail.x, dy = y - tail.y;
    if (Math.abs(dx) + Math.abs(dy) !== 1) return;
    let mv = null;
    if (dx === 1) mv = 'RIGHT';
    else if (dx === -1) mv = 'LEFT';
    else if (dy === 1) mv = 'DOWN';
    else if (dy === -1) mv = 'UP';
    if (mv) {
      autoRunArmedRef.current = true;
      setQueue(q => [...q, mv]);
      MazeAudio.sfx.queue();
    }
  };

  const handleCellDrag = (x, y) => {
    if (modalOpen || modalResolver) return;
    // Build phase: paint walls along the drag stroke, using the mode locked in
    // on pointerdown. Only the active builder may paint in match mode; the
    // spectator's drag pops fidget balloons across the cells they trace.
    if (game.phase === 'build') {
      if (game.kind === 'match' && game.role !== game.activeBuilder) {
        popFidgetCell(x, y);
        return;
      }
      if (buildStrokeRef.current === 'add') tryPlaceWall(x, y, { silent: true });
      else if (buildStrokeRef.current === 'remove') tryRemoveWall(x, y);
      return;
    }
    // Match-mode spectator (the round's builder) shouldn't queue a ghost
    // path on their own screen while watching the mover.
    if (game.kind === 'match' && game.role === game.activeBuilder) return;
    if (game.phase !== 'execute' || executing) return;
    if (!startTile) {
      if (y === ROWS - 1) setStartTile({ x, y });
      return;
    }
    const tail = computeTail(startTile, queue);
    if (x === tail.x && y === tail.y) return;
    const dx = x - tail.x, dy = y - tail.y;
    if (Math.abs(dx) + Math.abs(dy) !== 1) return;
    let mv = null;
    if (dx === 1) mv = 'RIGHT';
    else if (dx === -1) mv = 'LEFT';
    else if (dy === 1) mv = 'DOWN';
    else if (dy === -1) mv = 'UP';
    if (mv) {
      autoRunArmedRef.current = true;
      setQueue(q => [...q, mv]);
      MazeAudio.sfx.queue();
    }
  };

  const queueMove = (dir) => {
    if (modalOpen || modalResolver) return;
    if (executing) return;
    if (queue.length >= 25) return;
    // Validate the move against grid bounds — keyboard input would otherwise
    // walk the cursor off the board, leaving the user to undo their way back.
    const start = startTile || { x: Math.floor(COLS / 2), y: ROWS - 1 };
    const tail = startTile ? computeTail(startTile, queue) : start;
    let nx = tail.x, ny = tail.y;
    if (dir === 'UP') ny--;
    else if (dir === 'DOWN') ny++;
    else if (dir === 'LEFT') nx--;
    else if (dir === 'RIGHT') nx++;
    if (nx < 0 || nx >= COLS || ny < 0 || ny >= ROWS) {
      MazeAudio.sfx.invalid();
      return;
    }
    // Lateral move on the bottom row with no queue yet → slide the start tile
    // instead of queueing a sideways step. Matches what tapping a different
    // bottom-row cell does.
    if (queue.length === 0 && tail.y === ROWS - 1 && (dir === 'LEFT' || dir === 'RIGHT')) {
      setStartTile({ x: nx, y: ny });
      setQueue([]);
      MazeAudio.sfx.place();
      return;
    }
    if (!startTile) {
      // Auto-start at bottom-center — friendlier than a warning toast
      autoRunArmedRef.current = true;
      setStartTile(start);
      setQueue([dir]);
      MazeAudio.sfx.place();
    } else {
      autoRunArmedRef.current = true;
      setQueue(q => [...q, dir]);
      MazeAudio.sfx.queue();
    }
  };

  const undoMove = () => {
    if (modalOpen || modalResolver) return;
    if (queue.length === 0) return;
    setQueue(q => q.slice(0, -1));
    MazeAudio.sfx.tap();
  };

  // Auto-run: as soon as the sketched path reaches the top row, kick off
  // execution. Replaces the old Run button — sketch all the way up and the
  // blob takes off on its own. Gated by autoRunArmedRef so it only fires from
  // a fresh user move, not from a stale queue surviving a phase transition
  // (post-run modal close still has the old queue around for a render).
  useEffect(() => {
    if (!autoRunArmedRef.current) return;
    if (game.phase !== 'execute') return;
    if (executing) return;
    if (modalOpen || modalResolver) return;
    if (!startTile || queue.length === 0) return;
    const tail = computeTail(startTile, queue);
    if (tail.y === 0) {
      autoRunArmedRef.current = false;
      executeRun();
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [queue, startTile, game.phase, executing, modalOpen, modalResolver]);

  // Keyboard
  useEffect(() => {
    if (game.phase !== 'execute') return;
    const onKey = (e) => {
      // Modal up (e.g. post-run results) — let it own the keyboard so arrow
      // keys don't queue ghost moves on the board behind it.
      if (modalResolver) return;
      if (e.key === 'ArrowUp'    || e.key === 'w') { queueMove('UP'); }
      if (e.key === 'ArrowDown'  || e.key === 's') { queueMove('DOWN'); }
      if (e.key === 'ArrowLeft'  || e.key === 'a') { queueMove('LEFT'); }
      if (e.key === 'ArrowRight' || e.key === 'd') { queueMove('RIGHT'); }
      if (e.key === 'Backspace'  || e.key === 'z') { undoMove(); }
      if (e.key === 'Enter') executeRun();
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [game.phase, startTile, queue, executing]);

  // Spectator runner mirror: when the active mover broadcasts a runId, the
  // other player replays the same animation locally so they can watch the run.
  useEffect(() => {
    if (game.kind !== 'match') return;
    if (!game.runId) return;
    // Only the spectator (NOT the active mover) runs this mirror — the mover
    // already animates locally via executeRun.
    const moverRole = game.activeBuilder === 'p1' ? 'p2' : 'p1';
    if (game.role === moverRole) return;
    const q = game.runQueue || [];
    const start = game.runStart;
    if (!start || q.length === 0) return;
    let cancelled = false;
    (async () => {
      setExecuting(true);
      setRunStep(0);
      let x = start.x, y = start.y;
      setRunnerDot({ style: runnerDotStyle(x, y) });
      const boardRunning = cloneBoard(game.board);
      // Mirror start-on-wall crash so the audience sees the same instant fail
      if (boardRunning[y]?.[x] === 1) {
        await sleep(140);
        if (cancelled) return;
        setRunnerDot({ style: runnerDotStyle(x, y), hit: true });
        MazeAudio.sfx.wallHit();
        boardRef.current?.classList.add('shake');
        burstWall(x, y);
        setTimeout(() => boardRef.current?.classList.remove('shake'), 460);
        await sleep(360);
        setExecuting(false);
        return;
      }
      await sleep(520);
      if (cancelled) return;
      for (let i = 0; i < q.length; i++) {
        const m = q[i];
        let nx = x, ny = y;
        if (m === 'UP') ny--;
        else if (m === 'DOWN') ny++;
        else if (m === 'LEFT') nx--;
        else if (m === 'RIGHT') nx++;
        if (nx < 0 || nx >= COLS || ny < 0 || ny >= ROWS) break;
        if (boardRunning[ny]?.[nx] === 1) {
          setRunnerDot({ style: runnerDotStyle(nx, ny), hit: true });
          MazeAudio.sfx.wallHit();
          boardRef.current?.classList.add('shake');
          burstWall(nx, ny);
          setTimeout(() => boardRef.current?.classList.remove('shake'), 460);
          await sleep(280);
          break;
        }
        x = nx; y = ny;
        setRunnerDot({ style: runnerDotStyle(x, y) });
        await sleep(260);
        if (cancelled) return;
        // Trail catches up after the dot has visually arrived (matches the
        // mover's local timing in executeRun above).
        setRunStep(i + 1);
        if (boardRunning[y][x] === 3) { boardRunning[y][x] = 0; burstStars(x, y); }
        if (y === 0) {
          setOverlay(o => { const nn = cloneBoard(o); nn[y][x] = 'win'; return nn; });
          burstStars(x, y);
          await sleep(380);
          break;
        }
      }
      if (cancelled) return;
      // Don't clear runnerDot here — the snapshot listener will tick the phase
      // forward when the mover finishes (round-end / matchEnd).
      setExecuting(false);
    })();
    return () => { cancelled = true; };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [game.runId]);

  const computeTail = (start, q) => {
    let x = start.x, y = start.y;
    for (const m of q) {
      if (m === 'UP') y--;
      else if (m === 'DOWN') y++;
      else if (m === 'LEFT') x--;
      else if (m === 'RIGHT') x++;
    }
    return { x, y };
  };

  // Ghost path overlay. During planning the queued path renders as a violet
  // trail with a ghost-end pip and walls hidden (blind mode). During the
  // actual run we keep the trail overlaid so the runner's progress reads
  // against it — but walls reveal (coral) and the start/end markers drop
  // out so the runner dot can speak for itself.
  const ghostOverlay = React.useMemo(() => {
    const ov = makeEmptyBoard();
    if (game.phase !== 'execute') return ov;
    if (!executing) {
      for (let y = 0; y < ROWS; y++) for (let x = 0; x < COLS; x++) {
        if (game.board?.[y]?.[x] === 1) ov[y][x] = 'hide';
      }
    }
    if (!startTile) {
      if (!executing) {
        for (let x = 0; x < COLS; x++) ov[ROWS - 1][x] = 'start';
      }
      return ov;
    }
    if (!executing) ov[startTile.y][startTile.x] = 'start-mark';
    let x = startTile.x, y = startTile.y;
    for (let i = 0; i < queue.length; i++) {
      // During the run, the trail only extends to cells the runner has
      // actually completed — runStep is the count of finished moves so far,
      // so queue[i]'s destination is "visited" iff i < runStep. Cells past
      // that stay unmarked (and walls there can render their revealed coral).
      if (executing && i >= runStep) break;
      const m = queue[i];
      if (m === 'UP') y--;
      else if (m === 'DOWN') y++;
      else if (m === 'LEFT') x--;
      else if (m === 'RIGHT') x++;
      if (x < 0 || x >= COLS || y < 0 || y >= ROWS) break;
      if (!executing && i === queue.length - 1) ov[y][x] = 'ghost-end';
      else ov[y][x] = 'ghost';
    }
    return ov;
  }, [game.phase, game.board, startTile, queue, executing, runStep]);

  // Runner dot position helper
  const runnerDotStyle = (x, y) => {
    if (!boardRef.current) return null;
    const cells = boardRef.current.querySelectorAll('.cell');
    const cell = cells[y * COLS + x];
    if (!cell) return null;
    const boardRect = boardRef.current.getBoundingClientRect();
    const cellRect = cell.getBoundingClientRect();
    const cx = cellRect.left - boardRect.left + cellRect.width / 2 - 17;
    const cy = cellRect.top - boardRect.top + cellRect.height / 2 - 17;
    return { transform: `translate(${cx}px, ${cy}px)` };
  };

  // Execute run animation
  const executeRun = async () => {
    if (modalOpen || modalResolver) return;
    if (!startTile || queue.length === 0 || executing) { MazeAudio.sfx.invalid(); return; }
    // Broadcast queue + start tile to Firestore BEFORE animating, so the
    // spectator can mirror the runner dot. runId is a fresh nonce so the
    // spectator's effect fires even if queue happens to repeat.
    if (game.kind === 'match' && window.MazeNet) {
      window.MazeNet.updateMatch({
        runQueue: [...queue],
        runStart: { x: startTile.x, y: startTile.y },
        runId: Date.now(),
      });
    }
    setExecuting(true);
    setRunStep(0);
    setMascotMood('think'); setMascotMsg(null);

    // Reveal walls briefly underneath hidden state
    // Start animation
    let x = startTile.x, y = startTile.y;
    setRunnerDot({ style: runnerDotStyle(x, y) });

    const board = game.board;
    const starsCollected = [];
    const boardRunning = cloneBoard(board);

    let survived = false;
    let hitAt = null;
    let actualMoves = 0;

    // Picked a hidden wall as the start tile — flash the crash immediately so
    // the loss isn't ambiguous. Skip the queue entirely; nothing was traveled.
    if (boardRunning[y][x] === 1) {
      await sleep(140);
      setRunnerDot({ style: runnerDotStyle(x, y), hit: true });
      MazeAudio.sfx.wallHit();
      boardRef.current?.classList.add('shake');
      burstWall(x, y);
      setTimeout(() => boardRef.current?.classList.remove('shake'), 460);
      hitAt = { x, y };
      await sleep(360);
    } else {
    MazeAudio.sfx.step();
    await sleep(520);
    if (boardRunning[y][x] === 3) { starsCollected.push({x,y}); boardRunning[y][x] = 0; burstStars(x, y); }

    for (let i = 0; i < queue.length; i++) {
      const m = queue[i];
      let nx = x, ny = y;
      if (m === 'UP') ny--;
      else if (m === 'DOWN') ny++;
      else if (m === 'LEFT') nx--;
      else if (m === 'RIGHT') nx++;

      if (nx < 0 || nx >= COLS || ny < 0 || ny >= ROWS) {
        hitAt = { x, y };
        break;
      }

      if (boardRunning[ny][nx] === 1) {
        // Hit a wall — animate dot in, flash
        setRunnerDot({ style: runnerDotStyle(nx, ny), hit: true });
        await sleep(280);
        MazeAudio.sfx.wallHit();
        boardRef.current?.classList.add('shake');
        burstWall(nx, ny);
        setTimeout(() => boardRef.current?.classList.remove('shake'), 460);
        hitAt = { x: nx, y: ny };
        break;
      }

      x = nx; y = ny;
      actualMoves++;
      setRunnerDot({ style: runnerDotStyle(x, y) });
      // Wait for the runner-dot CSS transition to finish before lighting up
      // the trail cell, so the ghost appears at the moment the dot arrives
      // rather than before. (The dot's transition is 280ms; sleep is 260ms.)
      await sleep(260);
      setRunStep(i + 1);
      MazeAudio.sfx.step();
      if (boardRunning[y][x] === 3) {
        starsCollected.push({ x, y });
        boardRunning[y][x] = 0;
        MazeAudio.sfx.star();
        burstStars(x, y);
      }
      if (y === 0) {
        survived = true;
        // Splash on the winning cell + kick the board's bounce/splash off
        // concurrently. Triggering celebrate here (rather than after the
        // post-loop wall reveal) means the reaction lands the same frame as
        // the breach, which reads much snappier.
        setOverlay(o => { const nn = cloneBoard(o); nn[y][x] = 'win'; return nn; });
        burstStars(x, y);
        MazeAudio.sfx.roundWin();
        boardRef.current?.classList.add('celebrate');
        setTimeout(() => boardRef.current?.classList.remove('celebrate'), 800);
        await sleep(380);
        break;
      }
    }
    }

    await sleep(340);

    // Reveal all walls now
    setOverlay(o => {
      const nn = cloneBoard(o);
      for (let yy = 0; yy < ROWS; yy++) for (let xx = 0; xx < COLS; xx++) {
        nn[yy][xx] = 0;
      }
      return nn;
    });

    // Score: breach + stars − move cost (floored at 0). Per-move cost already
    // implicitly punishes doubling back (a revisit is 2 extra moves), so a
    // separate backtrack penalty was double-charging revisits and making
    // diamond detours uneconomical.
    const starsPts = starsCollected.length * 25;
    const breach = survived ? 50 : 0;
    const movePenalty = actualMoves * 3;
    const score = Math.max(0, breach + starsPts - movePenalty);

    // Feedback
    if (survived) {
      setMascotMood('cheer');
      setMascotMsg(`+${score} points!`);
    } else {
      MazeAudio.sfx.loss();
      setMascotMood('oof');
      setMascotMsg('oof.');
    }

    await sleep(800);

    // Branch based on game kind
    if (game.kind === 'solo') {
      setRunnerDot(null); setExecuting(false);
      if (survived) onResult?.({ kind: 'solo', winner: game.p2.name });
      const breakdownItems = [
        survived && { value: breach, label: 'breach', kind: 'pos' },
        { value: starsPts, label: `stars (×${starsCollected.length})`, kind: 'pos' },
        { value: -movePenalty, label: `moves (×${actualMoves})`, kind: 'neg' },
      ].filter(Boolean);
      const again = await showModal({
        title: survived ? 'You escaped!' : 'You crashed!',
        tone: survived ? 'success' : 'fail',
        breakdown: breakdownItems,
        total: score,
        message: survived
          ? 'Congratulations! Another board?'
          : 'No shame. Want another shot?',
        confirmText: survived ? 'Next board' : 'Try again',
        cancelText: 'Back to menu',
      });
      if (again) {
        const profile = DIFFICULTY_PROFILES[game.profileKey] || DIFFICULTY_PROFILES.medium;
        const board = generateRandomBoard(profile);
        setGame(g => ({ ...g, board, phase: 'memorize', deadline: Date.now() + profile.memorizeMs, round: (g.round||1) + 1 }));
        MazeAudio.sfx.reveal();
      } else onExit();
      return;
    }

    if (game.kind === 'gauntlet') {
      const total = (game.totalScore || 0) + score;
      const aggStars = (game.totalStars || 0) + starsCollected.length;
      const aggMoves = (game.totalMoves || 0) + actualMoves;
      setRunnerDot(null); setExecuting(false);

      const levelBreakdownItems = [
        survived && { value: breach, label: 'breach', kind: 'pos' },
        { value: starsPts, label: `stars (×${starsCollected.length})`, kind: 'pos' },
        { value: -movePenalty, label: `moves (×${actualMoves})`, kind: 'neg' },
      ].filter(Boolean);

      if (!survived || game.level >= game.totalLevels) {
        const levelsCompleted = survived ? game.totalLevels : game.level - 1;
        const aggBreach = levelsCompleted * 50;
        const aggStarsPts = aggStars * 25;
        const aggMovePenalty = aggMoves * 3;
        onResult?.({ kind: 'gauntlet', winner: game.p2.name, levels: levelsCompleted, points: total, seed: game.seedStr });
        await showModal({
          title: game.level >= game.totalLevels && survived ? 'Gauntlet cleared!' : `Ended on level ${game.level}`,
          tone: survived ? 'success' : 'fail',
          breakdown: [
            { value: aggBreach, label: `breach (${levelsCompleted} level${levelsCompleted === 1 ? '' : 's'})`, kind: 'pos' },
            { value: aggStarsPts, label: `stars (×${aggStars})`, kind: 'pos' },
            { value: -aggMovePenalty, label: `moves (×${aggMoves})`, kind: 'neg' },
          ],
          total,
          message: `Seed: ${game.seedStr}`,
          confirmText: 'Back to menu', hideCancel: true,
          mascot: survived ? 'cheer' : 'think',
        });
        onExit();
        return;
      }
      // Loop until the player either continues or confirms cash-out. At higher
      // levels an accidental Cash-out tap would torch the run, so we gate it
      // behind a second confirm and let "Keep playing" bring back the original
      // level-clear modal instead of dumping them straight to the next level.
      let cashout = false;
      while (true) {
        const cont = await showModal({
          title: `Level ${game.level} cleared`,
          tone: 'success',
          breakdown: levelBreakdownItems,
          total: score,
          message: `Running score: ${total}. Onward to level ${game.level + 1}?`,
          confirmText: 'Continue', cancelText: 'Cash out',
          mascot: 'wink',
        });
        if (cont) break;
        // "Keep playing" is the highlighted (coral) primary action so a quick
        // mash-Enter on this modal keeps the run alive instead of torching it.
        // Cash-out lives in the plain-styled cancel slot.
        const keepPlaying = await showModal({
          title: 'Cash out for real?',
          message: `Lock in your ${total} points and head back to the menu? You'll lose the rest of the run.`,
          confirmText: 'Keep playing', cancelText: 'Yes, cash out',
          danger: true,
          mascot: 'think',
        });
        if (!keepPlaying) { cashout = true; break; }
        // else: re-show the level-clear modal
      }
      if (cashout) {
        const levelsCompleted = game.level;
        const aggBreach = levelsCompleted * 50;
        const aggStarsPts = aggStars * 25;
        const aggMovePenalty = aggMoves * 3;
        onResult?.({ kind: 'gauntlet', winner: game.p2.name, levels: levelsCompleted, points: total, seed: game.seedStr });
        await showModal({
          title: `Cashed out on level ${levelsCompleted}`,
          tone: 'success',
          breakdown: [
            { value: aggBreach, label: `breach (${levelsCompleted} level${levelsCompleted === 1 ? '' : 's'})`, kind: 'pos' },
            { value: aggStarsPts, label: `stars (×${aggStars})`, kind: 'pos' },
            { value: -aggMovePenalty, label: `moves (×${aggMoves})`, kind: 'neg' },
          ],
          total,
          message: `Banked. Seed: ${game.seedStr}`,
          confirmText: 'Back to menu', hideCancel: true,
          mascot: 'wink',
        });
        onExit();
        return;
      }
      loadGauntletLevelLocal(game.level + 1, total, aggStars, aggMoves);
      return;
    }

    if (game.kind === 'match') {
      // Update score for current mover
      setRunnerDot(null); setExecuting(false);
      const moverKey = game.round === 1 ? 'p2' : 'p1';
      const nextP1 = moverKey === 'p1'
        ? { ...game.p1, score: game.p1.score + score, stars: (game.p1.stars || 0) + starsCollected.length }
        : game.p1;
      const nextP2 = moverKey === 'p2'
        ? { ...game.p2, score: game.p2.score + score, stars: (game.p2.stars || 0) + starsCollected.length }
        : game.p2;
      const nextGame = { ...game, p1: nextP1, p2: nextP2 };
      const roundBreakdownItems = [
        survived && { value: breach, label: 'breach', kind: 'pos' },
        { value: starsPts, label: `stars (×${starsCollected.length})`, kind: 'pos' },
        { value: -movePenalty, label: `moves (×${actualMoves})`, kind: 'neg' },
      ].filter(Boolean);
      if (game.round === 1) {
        // Broadcast breakdown + new scores BEFORE the mover's own modal so the
        // spectator's modal can fire as soon as their mirror animation ends —
        // they don't wait for the mover's "Start round 2" click.
        if (window.MazeNet) {
          window.MazeNet.updateMatch({
            p1: nextP1, p2: nextP2,
            lastRoundBreakdown: { round: 1, mover: nextP2.name, score, items: roundBreakdownItems },
          });
        }
        const ok = await showModal({
          title: `Round 1: ${nextP2.name} scored ${score}`,
          tone: 'success',
          breakdown: roundBreakdownItems,
          total: score,
          message: `Now swap roles. ${nextP2.name} builds the maze for ${nextP1.name} to run.`,
          confirmText: 'Start round 2', hideCancel: true,
          mascot: 'wink',
        });
        const r2Deadline = Date.now() + 60000;
        const emptyBoard = makeEmptyBoard();
        setGame({
          ...nextGame,
          round: 2,
          phase: 'build',
          deadline: r2Deadline,
          activeBuilder: 'p2',
          board: emptyBoard,
        });
        MazeAudio.sfx.confirm();
        if (window.MazeNet) {
          window.MazeNet.updateMatch({
            p1: nextP1, p2: nextP2,
            round: 2,
            phase: 'build',
            activeBuilder: 'p2',
            board: window.MazeNet.boardToFlat(emptyBoard),
            deadline: r2Deadline,
            runQueue: null, runStart: null, runId: null,
            wallProgress: 0,
          });
        }
      } else {
        // Match end
        const winner = nextP1.score > nextP2.score ? nextP1.name
                     : nextP2.score > nextP1.score ? nextP2.name
                     : 'Draw';
        if (winner !== 'Draw') { confettiBurst(); MazeAudio.sfx.matchWin(); }
        else MazeAudio.sfx.confirm();
        onResult?.({
          kind: 'match',
          p1: nextP1.name, p1Score: nextP1.score,
          p2: nextP2.name, p2Score: nextP2.score,
          winner,
        });
        if (window.MazeNet) {
          window.MazeNet.updateMatch({
            p1: nextP1, p2: nextP2, phase: 'gameover',
            runQueue: null, runStart: null, runId: null,
            lastRoundBreakdown: { round: 2, mover: nextP1.name, score, items: roundBreakdownItems },
          });
        }
        // Host (active mover at round-2 end) gets a "Play again / Back to menu"
        // choice. The joiner sees their own modal via the gameover effect below.
        const playAgain = await showModal({
          title: winner === 'Draw' ? "It's a tie!" : `${winner} wins!`,
          tone: winner === 'Draw' ? undefined : (winner === nextP1.name ? 'success' : 'fail'),
          breakdown: roundBreakdownItems,
          total: score,
          message:
            `Final\n` +
            `${nextP1.name}: ${nextP1.score}\n` +
            `${nextP2.name}: ${nextP2.score}\n\n` +
            `Play another match with ${nextP2.name}?`,
          confirmText: 'Play again',
          cancelText: 'Back to menu',
          mascot: winner === 'Draw' ? 'think' : 'cheer',
        });
        if (playAgain) {
          restartMatch(nextP1, nextP2);
        } else {
          onExit();
        }
      }
    }
  };

  const loadGauntletLevelLocal = (nextLevel, runningScore, runningStars, runningMoves) => {
    const profile = gauntletProfile(nextLevel);
    const board = generateRandomBoard(profile, game.seed * 1000 + nextLevel);
    setGame(g => ({
      ...g, board, level: nextLevel, difficulty: profile.walls, totalScore: runningScore,
      totalStars: runningStars ?? g.totalStars ?? 0,
      totalMoves: runningMoves ?? g.totalMoves ?? 0,
      phase: 'memorize', deadline: Date.now() + profile.memorizeMs, maxWalls: profile.walls,
      p2: { ...g.p2, score: runningScore },
    }));
    MazeAudio.sfx.reveal();
  };

  // Particle bursts
  const burstStars = (x, y) => { spawnParticles(x, y, 'star', 10); };
  const burstWall = (x, y) => { spawnParticles(x, y, 'wall', 12); };
  const spawnParticles = (x, y, kind, count) => {
    if (!boardRef.current) return;
    const cells = boardRef.current.querySelectorAll('.cell');
    const cell = cells[y * COLS + x];
    if (!cell) return;
    const rect = cell.getBoundingClientRect();
    const boardRect = boardRef.current.getBoundingClientRect();
    const cx = rect.left - boardRect.left + rect.width / 2;
    const cy = rect.top - boardRect.top + rect.height / 2;
    for (let i = 0; i < count; i++) {
      const p = document.createElement('div');
      p.className = `particle ${kind}`;
      const angle = (i / count) * Math.PI * 2 + Math.random() * 0.4;
      const dist = 38 + Math.random() * 22;
      p.style.left = `${cx}px`;
      p.style.top = `${cy}px`;
      p.style.setProperty('--tx', `${Math.cos(angle) * dist}px`);
      p.style.setProperty('--ty', `${Math.sin(angle) * dist}px`);
      p.style.setProperty('--rot', `${Math.floor(Math.random() * 720 - 360)}deg`);
      boardRef.current.appendChild(p);
      setTimeout(() => p.remove(), 720);
    }
  };

  // --- RENDER ---
  const isMatch = game.kind === 'match';
  const isLobby = game.phase === 'lobby';
  const isBuildPhase = game.phase === 'build';
  const isMemorize = game.phase === 'memorize';
  const isExecute = game.phase === 'execute';

  // Role gating: in solo/gauntlet/tutorial there's only one player so they always
  // get controls. In a match, only the active builder/mover gets controls.
  const isMyBuildTurn = !isMatch || game.role === game.activeBuilder;
  const isMyMoveTurn  = !isMatch || game.role !== game.activeBuilder;
  const opponentName = !isMatch ? '' :
    (game.role === 'p1' ? (game.p2?.name || 'Player 2') : (game.p1?.name || 'Player 1'));

  // Decide what to display on the board
  const displayBoard = isBuildPhase
    ? (isMyBuildTurn ? workingBoard : (game.board || makeEmptyBoard()))
    : game.board || makeEmptyBoard();

  const showStatusPill = () => {
    if (isLobby) return { msg: `Waiting for player… port ${game.port}`, variant: '' };
    if (isBuildPhase) {
      return isMyBuildTurn
        ? { msg: 'Place walls — leave a path to the top', variant: 'go' }
        : { msg: `${opponentName} is building the maze…`, variant: '' };
    }
    if (isMemorize) {
      if (!isMyMoveTurn) return { msg: `${opponentName} is memorizing…`, variant: '' };
      const t = Math.max(1, timeLeft);
      // Last 5s: pill goes urgent + flips to a "burn it in" warning. This
      // replaces the old red MEMORIZE timer-pill below the board.
      const urgent = timeLeft <= 5;
      return urgent
        ? { msg: `Burn the walls in — ${t}s`, variant: 'urgent' }
        : { msg: `Look only — the maze vanishes in ${t}s`, variant: '' };
    }
    if (isExecute) {
      if (!isMyMoveTurn) {
        // If this round's breakdown has landed, the mover is past their run
        // and just hasn't advanced the phase yet.
        if (game.lastRoundBreakdown && game.lastRoundBreakdown.round === game.round) {
          return { msg: `Round ${game.round} done — ${opponentName} is gearing up…`, variant: '' };
        }
        return { msg: `${opponentName} is running the maze…`, variant: '' };
      }
      // Three execute-phase states for the mover:
      //   no start tile     → tell them where to begin
      //   start, no moves   → invite them to plan a path (count of 0 reads dead)
      //   1+ moves queued   → live count, with singular/plural agreement
      const msg = !startTile
        ? 'Pick a start tile on the bottom row'
        : queue.length === 0
          ? 'Make your way to the top to escape'
          : `${queue.length} move${queue.length === 1 ? '' : 's'} queued`;
      return { msg, variant: '' };
    }
    return { msg: '', variant: '' };
  };
  const status = showStatusPill();

  const scoreCardP1 = { name: game.p1?.name || 'Maker', score: game.p1?.score || 0 };
  const scoreCardP2 = { name: game.p2?.name || 'Mover', score: game.p2?.score || 0 };

  return (
    <div className="stack scene-enter" style={{ gap: 8 }}>
      {game.kind === 'gauntlet' && (
        <div className="gauntlet-hud">
          <span>LVL {game.level}/{game.totalLevels}</span>
          <span>{game.difficulty} walls</span>
          <span>{game.totalScore}</span>
        </div>
      )}

      {isMatch && (
        <GameHeader
          p1={scoreCardP1}
          p2={scoreCardP2}
          port={game.port}
          round={game.round}
          mode={game.mode}
          activeIsP1={game.activeBuilder === 'p1'}
        />
      )}

      {isMatch && game.role === 'p1' && !game.p2?.name && (
        <div className="block tight scene-enter" style={{ textAlign: 'center', padding: 14 }}>
          <div className="label" style={{ marginBottom: 4 }}>Waiting for player · port</div>
          <div className="display" style={{ fontFamily: 'monospace', fontSize: 30, letterSpacing: '0.18em', color: 'var(--violet)', textShadow: '1px 1px 0 var(--ink)', marginBottom: 8 }}>{game.port}</div>
          <p className="body-sm" style={{ margin: '0 0 10px' }}>Share this code — keep building, they can join anytime.</p>
          <button className="btn btn-sm" onClick={async () => {
            try { await navigator.clipboard.writeText(game.port); toast('Port copied', 'success', 1400); }
            catch { toast('Copy failed', 'error', 1400); }
          }}>Copy port</button>
        </div>
      )}

      <div className={`status-pill ${status.variant}`}>
        <span className="dot" />
        <span>{status.msg}</span>
        {isMatch && (
          <button
            className="btn btn-sm btn-danger disconnect-btn"
            onClick={async () => {
              const ok = await showModal({
                title: 'Disconnect from match?',
                message: 'The other player will be told the room closed.',
                confirmText: 'Disconnect', cancelText: 'Stay', danger: true,
                mascot: 'oof',
              });
              if (ok) onExit();
            }}
          >Disconnect</button>
        )}
      </div>

      <Board
        board={displayBoard}
        overlay={mergeOverlays(overlay, ghostOverlay)}
        onCellTap={handleCellTap}
        onCellDrag={handleCellDrag}
        boardRef={boardRef}
        runnerDot={runnerDot}
      />

      {isBuildPhase && isMyBuildTurn && (
        <MakerControls
          timeLeft={timeLeft}
          wallsLeft={game.maxWalls - countWalls(workingBoard)}
          maxWalls={game.maxWalls}
          onDeploy={submitMaze}
          deployDisabled={modalOpen}
        />
      )}
      {isMatch && isBuildPhase && !isMyBuildTurn && (
        <SpectatorBuildView
          wallProgress={game.wallProgress || 0}
          maxWalls={game.maxWalls || 0}
          builderName={opponentName}
        />
      )}
      {isMemorize && isMyMoveTurn && (
        <MoverMemorize
          onReady={transitionToExecute}
          competitive={game.mode === 'comp'}
        />
      )}
      {isExecute && isMyMoveTurn && (
        <MoverExecute
          onUndo={undoMove}
          canUndo={queue.length > 0 && !executing && !modalOpen}
        />
      )}

      {mascotMsg && (
        <div className="callout-overlay">
          <div className="callout-bubble">{mascotMsg}</div>
        </div>
      )}
    </div>
  );
};

function mergeOverlays(a, b) {
  const out = makeEmptyBoard();
  for (let y = 0; y < ROWS; y++) for (let x = 0; x < COLS; x++) {
    out[y][x] = a[y][x] || b[y][x] || 0;
  }
  return out;
}

const sleep = (ms) => new Promise(r => setTimeout(r, ms));

// ============================================================
// Mount
// ============================================================
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);

window.__tweakVal = window.__tweakVal || {};
