"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import {
  chooseReachableGapY,
  constrainReachableGapY,
} from "../lib/obstacle-path";
import { GAMEPLAY_RULES } from "../lib/game-rules";
import {
  DEFAULT_SPIDER_SKIN,
  getSpiderSkin,
  isSpiderSkinId,
  SHARED_SPIDER_EYES,
  SPIDER_SKINS,
  SPIDER_SKIN_STORAGE_KEY,
  type SpiderSkinId,
} from "../lib/spider-skins";
import {
  getWebAnimationProfile,
  WEB_ANIMATION_PROFILES,
  type WebVisualPattern,
} from "../lib/web-animation-profiles";

type Mode = "menu" | "ready" | "playing" | "falling" | "over";

type Obstacle = { x: number; gapY: number; gap: number; passed: boolean; seed: number };
type WebContact = {
  sx: number;
  sy: number;
  x: number;
  y: number;
  attached: boolean;
  obstacle: Obstacle | null;
};
type WebAnchor = {
  obstacle: Obstacle | null;
  anchorDx: number;
  anchorY: number;
  fixedX: number;
  fixedY: number;
};
type WebTrace = {
  obstacle: Obstacle;
  skinId: SpiderSkinId;
  anchorDx: number;
  anchorY: number;
  freeDx: number;
  freeDy: number;
  life: number;
  maxLife: number;
  phase: number;
};

const BEST_SCORE_KEY = "webtug-best";
const LEGACY_BEST_SCORE_KEYS = ["yoyo-spider-best", "jumpy-arachnid-best", "jumpy-arana-best", "webswing-best"];
const CHALLENGE_PARAM = "beat";
const MAX_CHALLENGE_SCORE = 999999;

const parseChallengeTarget = (value:string|null) => {
  if (!value || !/^\d{1,6}$/.test(value)) return null;
  const target = Number.parseInt(value, 10);
  return Number.isFinite(target) && target >= 0 && target <= MAX_CHALLENGE_SCORE ? target : null;
};

const readStoredBest = () => [BEST_SCORE_KEY, ...LEGACY_BEST_SCORE_KEYS].reduce((best, key) => {
  const value = Number.parseInt(localStorage.getItem(key) || "0", 10);
  return Math.max(best, Number.isFinite(value) && value > 0 ? value : 0);
}, 0);

function GameLogo({ compact = false, heading = false }:{ compact?:boolean; heading?:boolean }) {
  const content = <>
    <span className="logo-line logo-yoyo" aria-hidden="true">WEB</span>
    <span className="logo-line logo-spider-word" aria-hidden="true">
      <span>TU</span>
      <span className="logo-spider">
        <span className="logo-spider-core">
          <span className="logo-spider-eyes"/>
          <span className="logo-spider-hourglass"/>
        </span>
      </span>
      <span>G</span>
    </span>
  </>;
  const className = `game-logo${compact ? " game-logo--compact" : ""}`;
  return heading
    ? <h1 className={className} aria-label="WebTug">{content}</h1>
    : <div className={className} role="img" aria-label="WebTug">{content}</div>;
}

function SpiderAvatar({ skinId }:{ skinId: SpiderSkinId }) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    const context = canvas?.getContext("2d");
    if (!canvas || !context) return;
    const skin = getSpiderSkin(skinId);
    const { appearance } = skin;
    const scale = 1.5;
    const centerX = canvas.width / 2;
    const centerY = canvas.height / 2 + 3;
    context.clearRect(0, 0, canvas.width, canvas.height);
    context.save();
    context.translate(centerX, centerY);
    context.scale(scale, scale);
    context.strokeStyle = appearance.leg;
    context.lineWidth = appearance.legWidth * .72;
    context.lineCap = "square";
    for (const side of [-1, 1]) {
      for (let index = -1.5; index <= 1.5; index += 1) {
        const y = index * 4.5;
        context.beginPath();
        context.moveTo(side * 8, y);
        context.lineTo(side * appearance.legReach * .67, y - appearance.legLift * .45);
        context.lineTo(side * appearance.legReach * .88, y + index * appearance.legDrop * .64);
        context.stroke();
      }
    }
    context.fillStyle = appearance.body;
    context.beginPath();
    if (appearance.bodyShape === "angular") {
      const rx = appearance.bodyRx * .78;
      const ry = appearance.bodyRy * .78;
      context.moveTo(0, -ry + 2);
      context.lineTo(rx, -ry * .2 + 2);
      context.lineTo(rx * .72, ry + 2);
      context.lineTo(-rx * .72, ry + 2);
      context.lineTo(-rx, -ry * .2 + 2);
      context.closePath();
    } else if (appearance.bodyShape === "slender") {
      context.ellipse(0, appearance.bodyY + 3, appearance.bodyRx * .76, appearance.bodyRy * .84, 0, 0, Math.PI * 2);
    } else if (appearance.bodyShape === "lobed") {
      const rx = appearance.bodyRx * .78;
      const ry = appearance.bodyRy * .78;
      context.moveTo(0, -ry + 2);
      context.bezierCurveTo(rx * .6, -ry, rx, -ry * .35, rx * .82, 2);
      context.bezierCurveTo(rx, ry * .5, rx * .42, ry + 2, 0, ry * .9 + 2);
      context.bezierCurveTo(-rx * .42, ry + 2, -rx, ry * .5, -rx * .82, 2);
      context.bezierCurveTo(-rx, -ry * .35, -rx * .6, -ry, 0, -ry + 2);
      context.closePath();
    } else {
      context.ellipse(0, appearance.bodyY + 3, appearance.bodyRx * .78, appearance.bodyRy * .78, 0, 0, Math.PI * 2);
    }
    context.fill();
    context.fillStyle = appearance.head;
    context.beginPath();
    context.arc(0, -8, 11, 0, Math.PI * 2);
    context.fill();
    context.fillStyle = SHARED_SPIDER_EYES.eyeColor;
    context.fillRect(-7.2, -13, 5.8, 5.8);
    context.fillRect(1.4, -13, 5.8, 5.8);
    context.fillStyle = SHARED_SPIDER_EYES.pupilColor;
    context.fillRect(-5.5, -11.3, 2.4, 2.4);
    context.fillRect(3.1, -11.3, 2.4, 2.4);
    context.fillStyle = appearance.accent;
    context.beginPath();
    context.arc(0, 8, 3.2, 0, Math.PI * 2);
    context.fill();
    context.restore();
  }, [skinId]);

  return <canvas ref={canvasRef} width={108} height={90} aria-hidden="true" />;
}

export default function GameClient() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const frameRef = useRef<number>(0);
  const modeRef = useRef<Mode>("menu");
  const soundRef = useRef(true);
  const audioRef = useRef<AudioContext | null>(null);
  const holdRef = useRef(false);
  const skinRef = useRef<SpiderSkinId>(DEFAULT_SPIDER_SKIN);
  const scoreRef = useRef(0);
  const bestRef = useRef(0);
  const resetGameRef = useRef<() => void>(() => undefined);
  const [mode, setMode] = useState<Mode>("menu");
  const [score, setScore] = useState(0);
  const [best, setBest] = useState(0);
  const [newBest, setNewBest] = useState(false);
  const [sound, setSound] = useState(true);
  const [challengeTarget, setChallengeTarget] = useState<number|null>(null);
  const [routeChecked, setRouteChecked] = useState(false);
  const [shareNotice, setShareNotice] = useState("");
  const [skinId, setSkinId] = useState<SpiderSkinId>(DEFAULT_SPIDER_SKIN);

  const getAudio = useCallback(() => {
    if (!audioRef.current) {
      const AudioContextCtor = window.AudioContext || (window as typeof window & { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
      audioRef.current = new AudioContextCtor();
    }
    if (audioRef.current.state === "suspended") void audioRef.current.resume();
    return audioRef.current;
  }, []);

  const beep = useCallback((frequency: number, duration = 0.07) => {
    if (!soundRef.current) return;
    const context = getAudio();
    const oscillator = context.createOscillator();
    const gain = context.createGain();
    oscillator.type = "sine";
    oscillator.frequency.value = frequency;
    gain.gain.setValueAtTime(0.09, context.currentTime);
    gain.gain.exponentialRampToValueAtTime(0.001, context.currentTime + duration);
    oscillator.connect(gain).connect(context.destination);
    oscillator.start();
    oscillator.stop(context.currentTime + duration);
  }, [getAudio]);

  const rewardSound = useCallback(() => {
    if (!soundRef.current) return;
    const context = getAudio();
    const start = context.currentTime;
    [1046.5, 1318.51, 1760].forEach((frequency, index) => {
      const oscillator = context.createOscillator();
      const gain = context.createGain();
      const noteStart = start + index * .055;
      oscillator.type = index === 2 ? "sine" : "triangle";
      oscillator.frequency.setValueAtTime(frequency, noteStart);
      gain.gain.setValueAtTime(.001, noteStart);
      gain.gain.exponentialRampToValueAtTime(index === 2 ? .105 : .075, noteStart + .012);
      gain.gain.exponentialRampToValueAtTime(.001, noteStart + .18);
      oscillator.connect(gain).connect(context.destination);
      oscillator.start(noteStart);
      oscillator.stop(noteStart + .2);
    });
  }, [getAudio]);

  const enterReady = useCallback(() => {
    resetGameRef.current();
    scoreRef.current = 0;
    setScore(0);
    setNewBest(false);
    setMode("ready");
    modeRef.current = "ready";
    beep(430, 0.07);
  }, [beep]);

  const beginRun = useCallback(() => {
    if (modeRef.current !== "ready") return;
    setMode("playing");
    modeRef.current = "playing";
    beep(560, 0.08);
  }, [beep]);

  useEffect(() => {
    const timer = window.setTimeout(() => {
      const safeBest = readStoredBest();
      bestRef.current = safeBest;
      localStorage.setItem(BEST_SCORE_KEY, String(safeBest));
      setBest(safeBest);
      const storedSkin = localStorage.getItem(SPIDER_SKIN_STORAGE_KEY);
      if (isSpiderSkinId(storedSkin)) {
        skinRef.current = storedSkin;
        setSkinId(storedSkin);
      }
    }, 0);
    return () => window.clearTimeout(timer);
  }, []);

  const chooseSkin = (nextSkin: SpiderSkinId) => {
    skinRef.current = nextSkin;
    setSkinId(nextSkin);
    localStorage.setItem(SPIDER_SKIN_STORAGE_KEY, nextSkin);
  };

  useEffect(() => {
    const timer = window.setTimeout(() => {
      const url = new URL(window.location.href);
      const rawTarget = url.searchParams.get(CHALLENGE_PARAM);
      if (rawTarget === null) {
        setRouteChecked(true);
        return;
      }
      const target = parseChallengeTarget(rawTarget);
      if (target === null) {
        url.searchParams.delete(CHALLENGE_PARAM);
        window.history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`);
        setRouteChecked(true);
        return;
      }
      resetGameRef.current();
      scoreRef.current = 0;
      modeRef.current = "ready";
      setChallengeTarget(target);
      setScore(0);
      setNewBest(false);
      setMode("ready");
      setRouteChecked(true);
    }, 0);
    return () => window.clearTimeout(timer);
  }, []);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;
    let w = 0, h = 0, last = performance.now();
    let spider = { x: 0, y: 0, vy: 0, angle: 0 };
    let obstacles: Obstacle[] = [];
    let particles: { x: number; y: number; life: number; vx: number; vy: number; color?: string; size?: number }[] = [];
    let distance = 0;
    let fallTime = 0;
    let webShotAge = 1;
    let webTraces: WebTrace[] = [];
    let webTarget: WebAnchor | null = null;
    let webAttached = false;

    const worldScale = () => Math.max(.8, Math.min(1.08, h / 680));
    const worldSpeed = () => 185 * worldScale();
    const obstacleSpacing = () => 248 * worldScale();
    const obstacleGap = () => 174 * worldScale();

    const makeObstacle = (x: number): Obstacle => {
      const gap = obstacleGap();
      const previous = obstacles[obstacles.length - 1];
      const previousPrevious = obstacles[obstacles.length - 2];
      return {
        x,
        gapY: chooseReachableGapY({
          arenaHeight: h,
          gapHeight: gap,
          scale: worldScale(),
          portrait: h > w,
          previousGapY: previous?.gapY ?? spider.y,
          previousPreviousGapY: previousPrevious?.gapY,
        }),
        gap,
        passed: false,
        seed: Math.random() * Math.PI * 2,
      };
    };

    const reset = () => {
      spider = { x: w * 0.27, y: h * 0.48, vy: 0, angle: 0 };
      obstacles = [];
      particles = [];
      holdRef.current = false;
      distance = 0;
      fallTime = 0;
      webShotAge = 1;
      webTraces = [];
      webTarget = null;
      webAttached = false;
      const firstX = spider.x + worldSpeed() * 1.65;
      const count = Math.max(5, Math.ceil((w - firstX) / obstacleSpacing()) + 4);
      for (let i = 0; i < count; i++) obstacles.push(makeObstacle(firstX + i * obstacleSpacing()));
    };
    resetGameRef.current = reset;
    let initialized = false;
    const resize = () => {
      const oldH = h;
      const oldSpiderX = spider.x;
      const oldWorldScale = oldH ? Math.max(.8, Math.min(1.08, oldH / 680)) : 1;
      const rect = canvas.getBoundingClientRect();
      const dpr = Math.min(2, window.devicePixelRatio || 1);
      w = rect.width; h = rect.height;
      canvas.width = Math.max(1, Math.round(w * dpr));
      canvas.height = Math.max(1, Math.round(h * dpr));
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.imageSmoothingEnabled = true;
      if (!initialized || modeRef.current === "menu" || modeRef.current === "ready" || modeRef.current === "over") {
        initialized = true;
        reset();
        return;
      }
      const verticalScale = oldH ? h / oldH : 1;
      const timingScale = worldScale() / oldWorldScale;
      spider.x = w * .27;
      spider.y = Math.max(20, Math.min(h - GAMEPLAY_RULES.collision.groundClearance, spider.y * verticalScale));
      spider.vy *= timingScale;
      let previousGapY = spider.y;
      let previousPreviousGapY: number | undefined;
      for (const obstacle of obstacles) {
        const gap = obstacleGap();
        obstacle.x = spider.x + (obstacle.x - oldSpiderX) * timingScale;
        obstacle.gapY = constrainReachableGapY({
          arenaHeight: h,
          gapHeight: gap,
          scale: worldScale(),
          desiredGapY: obstacle.gapY * verticalScale,
          portrait: h > w,
          previousGapY,
          previousPreviousGapY,
        });
        obstacle.gap = gap;
        previousPreviousGapY = previousGapY;
        previousGapY = obstacle.gapY;
      }
      webTraces = webTraces.map((trace) => ({
        ...trace,
        anchorY: trace.anchorY * verticalScale,
        freeDx: trace.freeDx * timingScale,
        freeDy: trace.freeDy * verticalScale,
      }));
      webTarget = null;
      webAttached = false;
      if (holdRef.current) webShotAge = 0;
    };

    const drawCloud = (x:number,y:number,scale:number) => {
      ctx.save();ctx.translate(x,y);ctx.scale(scale,scale);ctx.fillStyle="rgba(246,245,237,.7)";
      ctx.beginPath();ctx.arc(-24,4,20,0,Math.PI*2);ctx.arc(0,-6,27,0,Math.PI*2);ctx.arc(28,5,19,0,Math.PI*2);ctx.fillRect(-28,3,58,22);ctx.fill();ctx.restore();
    };
    const seeded01 = (seed:number) => {
      const value = Math.sin(seed * 12.9898 + 78.233) * 43758.5453;
      return value - Math.floor(value);
    };
    const drawBackgroundHouse = (x:number, baseY:number, scale:number, seed:number) => {
      const bodyColors = ["#dad2c2","#c9c9bf","#dfd6c6","#cbd1c7","#d4c9bb"];
      const roofColors = ["#5d6f5f","#677666","#4f6a5d","#757868","#576a5f"];
      const doorColors = ["#8e8477","#7b746b","#8d7d6f","#747067"];
      const houseW = 22 + seeded01(seed + 1.2) * 11;
      const houseH = 14 + seeded01(seed + 2.4) * 8;
      const roofRise = 9 + seeded01(seed + 3.6) * 7;
      const roofLean = (seeded01(seed + 4.8) - .5) * 5;
      const roofStyle = Math.floor(seeded01(seed + 5.4) * 3);
      const chimneySide = seeded01(seed + 6.1) > .5 ? 1 : -1;
      const chimneyX = chimneySide * houseW * (.19 + seeded01(seed + 7.3) * .12);
      const chimneyTop = -houseH - roofRise * (.55 + seeded01(seed + 8.5) * .15);
      const hasSmoke = seeded01(seed + 9.7) > .12;
      const bodyColor=bodyColors[Math.floor(seeded01(seed+10.9)*bodyColors.length)];
      const roofColor=roofColors[Math.floor(seeded01(seed+12.1)*roofColors.length)];
      ctx.save();
      ctx.translate(x,baseY);
      ctx.scale(scale,scale);
      if(hasSmoke){
        for(let i=0;i<3;i++){
          const lift=(last*.018+seed*19+i*18)%58;
          const drift=lift*.12+Math.sin(last*.0022+seed+i)*1.6;
          ctx.fillStyle=`rgba(118,112,104,${Math.max(0,.36*(1-lift/58))})`;
          ctx.beginPath();
          ctx.arc(chimneyX+drift,chimneyTop-3-lift*.58,2.7+lift*.032,0,Math.PI*2);
          ctx.fill();
        }
      }
      ctx.fillStyle="#77746e";
      ctx.fillRect(chimneyX-2,chimneyTop,4,11);
      if(seeded01(seed+11.5)>.69){
        const annexSide=seeded01(seed+11.8)>.5?1:-1;
        const annexW=houseW*.42;
        const annexH=houseH*(.48+seeded01(seed+11.9)*.16);
        const annexX=annexSide*(houseW/2+annexW/2-2);
        ctx.fillStyle=bodyColor;
        ctx.fillRect(annexX-annexW/2,-annexH,annexW,annexH);
        ctx.fillStyle=roofColor;
        ctx.beginPath();
        ctx.moveTo(annexX-annexW/2-2,-annexH+1);
        ctx.lineTo(annexX-annexW*.08,-annexH-roofRise*.48);
        ctx.lineTo(annexX+annexW/2+2,-annexH+1);
        ctx.closePath();
        ctx.fill();
      }
      ctx.fillStyle=bodyColor;
      ctx.fillRect(-houseW/2,-houseH,houseW,houseH);
      ctx.fillStyle=roofColor;
      ctx.beginPath();
      if(roofStyle===0){
        ctx.moveTo(-houseW/2-4,-houseH+1);
        ctx.lineTo(roofLean,-houseH-roofRise);
        ctx.lineTo(houseW/2+4,-houseH+1);
      }else if(roofStyle===1){
        ctx.moveTo(-houseW/2-4,-houseH+1);
        ctx.lineTo(-houseW*.31+roofLean,-houseH-roofRise*.72);
        ctx.lineTo(houseW*.31+roofLean,-houseH-roofRise*.72);
        ctx.lineTo(houseW/2+4,-houseH+1);
      }else{
        ctx.moveTo(-houseW/2-4,-houseH+1);
        ctx.lineTo(-houseW*.26+roofLean,-houseH-roofRise*.88);
        ctx.lineTo(houseW*.38+roofLean,-houseH-roofRise*.52);
        ctx.lineTo(houseW/2+4,-houseH+1);
      }
      ctx.closePath();
      ctx.fill();
      const doorW=5+seeded01(seed+13.3)*2;
      ctx.fillStyle=doorColors[Math.floor(seeded01(seed+14.5)*doorColors.length)];
      ctx.fillRect(-doorW/2,-houseH*.58,doorW,houseH*.58);
      ctx.fillStyle="rgba(218,228,215,.86)";
      const windowCount=houseW>28&&seeded01(seed+15.7)>.35?2:1;
      for(let i=0;i<windowCount;i++){
        const windowX=windowCount===1?-houseW*.27:(i===0?-houseW*.3:houseW*.3);
        ctx.fillRect(windowX-2.4,-houseH*.67,4.8,4.5);
      }
      if(seeded01(seed+16.9)>.42){
        ctx.fillStyle="rgba(90,115,79,.72)";
        const bushSide=seeded01(seed+18.1)>.5?1:-1;
        ctx.beginPath();
        ctx.arc(bushSide*(houseW*.38),-2.6,3.5,0,Math.PI*2);
        ctx.arc(bushSide*(houseW*.49),-2.1,2.8,0,Math.PI*2);
        ctx.fill();
      }
      ctx.restore();
    };
    const drawBackgroundVillage = (
      centerX:number,
      terrainY:(x:number)=>number,
      scale:number,
      seed:number,
    ) => {
      const count=2+Math.floor(seeded01(seed*3.7+1.9)*4);
      const spacing=27+seeded01(seed*5.1+2.8)*13;
      const houses:Array<{x:number;y:number;scale:number;seed:number;depth:number}>=[];
      for(let i=0;i<count;i++){
        const order=i-(count-1)/2;
        const houseSeed=seed*41+i*11.7+3.2;
        const depth=seeded01(houseSeed+4.4);
        const houseX=centerX+order*spacing+(seeded01(houseSeed+5.6)-.5)*11;
        houses.push({
          x:houseX,
          y:terrainY(houseX)+2-(depth>.72?4:0),
          scale:(.5+seeded01(houseSeed+6.8)*.34)*(depth>.72?.88:1)*scale,
          seed:houseSeed,
          depth,
        });
      }
      houses.sort((a,b)=>b.depth-a.depth);
      ctx.strokeStyle="rgba(219,207,178,.34)";
      ctx.lineWidth=Math.max(1,1.4*scale);
      ctx.beginPath();
      houses.forEach((house,index)=>{
        const pathY=terrainY(house.x)+3;
        if(index===0)ctx.moveTo(house.x,pathY);
        else ctx.lineTo(house.x,pathY);
      });
      ctx.stroke();
      const treeCount=seeded01(seed*7.3+4.6)>.48?2:1;
      for(let i=0;i<treeCount;i++){
        const treeX=centerX+(seeded01(seed*17+i*8.2+5.8)-.5)*spacing*(count+.7);
        const treeY=terrainY(treeX)+2;
        const treeScale=(.65+seeded01(seed*19+i*6.4+7.1)*.38)*scale;
        ctx.save();
        ctx.translate(treeX,treeY);
        ctx.scale(treeScale,treeScale);
        ctx.fillStyle="rgba(70,104,75,.72)";
        ctx.beginPath();
        ctx.moveTo(0,-24);ctx.lineTo(-8,-7);ctx.lineTo(-3,-7);
        ctx.lineTo(-10,0);ctx.lineTo(10,0);ctx.lineTo(3,-7);ctx.lineTo(8,-7);
        ctx.closePath();ctx.fill();
        ctx.restore();
      }
      for(const house of houses)drawBackgroundHouse(house.x,house.y,house.scale,house.seed);
    };
    const getSpiderPose = () => {
      const playing = modeRef.current === "playing";
      const webbing = playing && holdRef.current;
      const climbing = webbing && spider.vy < -35;
      const climbPhase = last * GAMEPLAY_RULES.animation.climbPhaseRate;
      const climbPulse = climbing ? Math.sin(climbPhase) : 0;
      const idleBob = modeRef.current === "ready" ? Math.sin(last / 270) * 6 : 0;
      const firing = webbing && webShotAge < GAMEPLAY_RULES.animation.webFlashSeconds;
      let scaleX = 1;
      let scaleY = 1;
      if (firing) {
        scaleX = .92;
        scaleY = 1.08;
      } else if (climbing) {
        scaleX = .98 + Math.abs(climbPulse) * .025;
        scaleY = 1.03 - Math.abs(climbPulse) * .02;
      }
      return {
        x: spider.x + (climbing ? climbPulse * 1.6 : 0),
        y: spider.y + idleBob + (climbing ? Math.abs(climbPulse) * 2.2 : 0),
        rotation: spider.angle + (webbing ? -.09 : 0),
        playing,
        webbing,
        climbing,
        climbPhase,
        climbPulse,
        firing,
        scaleX,
        scaleY,
      };
    };
    const pointOnSpider = (pose: ReturnType<typeof getSpiderPose>, x: number, y: number) => {
      const cos = Math.cos(pose.rotation);
      const sin = Math.sin(pose.rotation);
      const scaledX = x * pose.scaleX;
      const scaledY = y * pose.scaleY;
      return { x: pose.x + scaledX * cos - scaledY * sin, y: pose.y + scaledX * sin + scaledY * cos };
    };
    const getWebAttachmentGeometry = () => {
      const appearance = getSpiderSkin(skinRef.current).appearance;
      const contourScale = appearance.bodyShape === "lobed" ? 1.08 : 1;
      return {
        x: appearance.bodyRx * contourScale + .5,
        y: appearance.bodyY,
        wrapY: Math.min(9.5, appearance.bodyRy * .53),
      };
    };
    const spiderLayerSize = 132;
    const spiderLayerPixels = 60;
    const spiderLayer = document.createElement("canvas");
    spiderLayer.width = spiderLayerPixels;
    spiderLayer.height = spiderLayerPixels;
    const pixelSpiderCtx = spiderLayer.getContext("2d");
    const traceSkinAbdomen = (
      g: CanvasRenderingContext2D,
      appearance: ReturnType<typeof getSpiderSkin>["appearance"],
      centerY: number,
      motionScaleX = 1,
      motionScaleY = 1,
    ) => {
      const rx = appearance.bodyRx * motionScaleX;
      const ry = appearance.bodyRy * motionScaleY;
      const y = centerY;
      g.beginPath();
      switch (appearance.bodyShape) {
        case "angular":
          g.moveTo(0, y - ry);
          g.lineTo(rx * .82, y - ry * .34);
          g.lineTo(rx, y + ry * .38);
          g.lineTo(rx * .34, y + ry);
          g.lineTo(-rx * .34, y + ry);
          g.lineTo(-rx, y + ry * .38);
          g.lineTo(-rx * .82, y - ry * .34);
          g.closePath();
          break;
        case "slender":
          g.moveTo(0, y - ry);
          g.bezierCurveTo(rx * .8, y - ry * .68, rx, y + ry * .35, rx * .42, y + ry);
          g.bezierCurveTo(-rx * .42, y + ry, -rx, y + ry * .35, 0, y - ry);
          g.closePath();
          break;
        case "lobed":
          g.moveTo(0, y - ry);
          g.bezierCurveTo(rx * .52, y - ry * 1.08, rx * 1.06, y - ry * .5, rx * .84, y);
          g.bezierCurveTo(rx * 1.08, y + ry * .5, rx * .45, y + ry, 0, y + ry * .92);
          g.bezierCurveTo(-rx * .45, y + ry, -rx * 1.08, y + ry * .5, -rx * .84, y);
          g.bezierCurveTo(-rx * 1.06, y - ry * .5, -rx * .52, y - ry * 1.08, 0, y - ry);
          g.closePath();
          break;
        default:
          g.ellipse(0, y + (appearance.bodyShape === "bulb" ? ry * .08 : 0), rx, ry, 0, 0, Math.PI * 2);
      }
    };
    const paintSpiderBase = (
      g: CanvasRenderingContext2D,
      pose: ReturnType<typeof getSpiderPose>,
      originX: number,
      originY: number,
    ) => {
      const crashed = modeRef.current === "falling";
      const { playing, webbing, climbing, climbPhase, firing } = pose;
      const dropping = crashed || (!webbing && playing && spider.vy > 95);
      const appearance = getSpiderSkin(skinRef.current).appearance;
      g.save();
      g.translate(originX, originY);
      g.rotate(pose.rotation);
      g.scale(pose.scaleX, pose.scaleY);
      if (dropping) {
        g.scale(crashed ? .86 : .94, crashed ? 1.14 : 1.07);
      }
      g.strokeStyle = crashed ? "#66564a" : appearance.leg; g.lineWidth = appearance.legWidth; g.lineCap = "butt"; g.lineJoin = "miter";
      for (const side of [-1, 1]) for (let i = -1.5; i <= 1.5; i++) {
        const y = i * 6;
        const pump = climbing ? Math.sin(climbPhase + i * 1.7 + (side < 0 ? Math.PI : 0)) * 4 : 0;
        const kneeReach = appearance.legReach * .72;
        g.beginPath(); g.moveTo(side * 10, y);
        if (dropping) {
          g.lineTo(side * kneeReach, y - appearance.legLift);
          g.lineTo(side * appearance.legReach * (crashed ? .78 : .94), y - appearance.legDrop + i * 5);
        } else if (webbing) {
          g.lineTo(side * kneeReach, y - appearance.legLift - pump);
          g.lineTo(side * appearance.legReach * (firing ? 1.08 : 1), y + i * 7 - appearance.legDrop + pump);
        } else {
          g.lineTo(side * kneeReach, y + i * 2);
          g.lineTo(side * appearance.legReach, y + i * appearance.legDrop);
        }
        g.stroke();
      }
      g.shadowBlur = 0;
      g.fillStyle = "rgba(48,43,39,.24)";
      traceSkinAbdomen(g, appearance, appearance.bodyY + 4, webbing ? .92 : 1, webbing ? 1.08 : 1); g.fill();
      g.fillStyle = crashed ? "#75675d" : appearance.body;
      traceSkinAbdomen(g, appearance, appearance.bodyY, webbing ? .92 : 1, webbing ? 1.08 : 1); g.fill();
      g.shadowBlur = 0; g.fillStyle = crashed ? "#917963" : appearance.head;
      g.beginPath(); g.arc(0, webbing ? -8 : -7, firing ? 16 : 15, 0, Math.PI * 2); g.fill();
      g.restore();
    };
    const paintSpiderOutline = (
      g: CanvasRenderingContext2D,
      pose: ReturnType<typeof getSpiderPose>,
      originX: number,
      originY: number,
    ) => {
      const crashed = modeRef.current === "falling";
      const { playing, webbing, firing } = pose;
      const dropping = crashed || (!webbing && playing && spider.vy > 95);
      const appearance = getSpiderSkin(skinRef.current).appearance;
      g.save();
      g.translate(originX, originY);
      g.rotate(pose.rotation);
      g.scale(pose.scaleX, pose.scaleY);
      if (dropping) g.scale(crashed ? .86 : .94, crashed ? 1.14 : 1.07);
      g.lineWidth = 1.8;
      g.lineCap = "butt";
      g.lineJoin = "miter";
      g.strokeStyle = "#45413d";
      traceSkinAbdomen(g, appearance, appearance.bodyY, webbing ? .92 : 1, webbing ? 1.08 : 1);
      g.stroke();
      g.strokeStyle = crashed ? "#66564a" : "#504a45";
      g.beginPath();
      g.arc(0, webbing ? -8 : -7, firing ? 16 : 15, 0, Math.PI * 2);
      g.stroke();
      g.restore();
    };
    const paintSpiderMotionEffects = (
      g: CanvasRenderingContext2D,
      pose: ReturnType<typeof getSpiderPose>,
      originX: number,
      originY: number,
    ) => {
      const crashed = modeRef.current === "falling";
      const { playing, webbing, climbing, climbPhase } = pose;
      const dropping = crashed || (!webbing && playing && spider.vy > 95);
      g.save();
      g.translate(originX, originY);
      g.rotate(pose.rotation);
      g.scale(pose.scaleX, pose.scaleY);
      if (dropping) {
        g.strokeStyle = crashed ? "rgba(231,222,204,.56)" : "rgba(91,86,80,.34)";
        g.lineWidth = 2;
        g.lineCap = "round";
        for (let i = -1; i <= 1; i++) {
          g.beginPath();
          g.moveTo(i * 11, -28 - Math.sin(fallTime * 9 + i) * 3);
          g.lineTo(i * 14, -43);
          g.stroke();
        }
      }
      if (climbing) {
        g.strokeStyle = "rgba(222,232,226,.45)";
        g.lineWidth = 1.5;
        g.lineCap = "round";
        for (let i = -1; i <= 1; i++) {
          const trail = Math.sin(climbPhase + i) * 3;
          g.beginPath();
          g.moveTo(i * 12 + trail, 25);
          g.lineTo(i * 12 - trail, 39 + Math.abs(trail));
          g.stroke();
        }
      }
      g.restore();
    };
    const paintSpiderDetails = (
      g: CanvasRenderingContext2D,
      pose: ReturnType<typeof getSpiderPose>,
      originX: number,
      originY: number,
    ) => {
      const crashed = modeRef.current === "falling";
      const { playing, webbing, firing } = pose;
      const dropping = crashed || (!webbing && playing && spider.vy > 95);
      const appearance = getSpiderSkin(skinRef.current).appearance;
      g.save();
      g.translate(originX, originY);
      g.rotate(pose.rotation);
      g.scale(pose.scaleX, pose.scaleY);
      if (dropping) g.scale(crashed ? .86 : .94, crashed ? 1.14 : 1.07);
      const eyeTop = webbing ? -13.2 : -11;
      g.fillStyle = SHARED_SPIDER_EYES.eyeColor;
      g.fillRect(SHARED_SPIDER_EYES.leftX, eyeTop, SHARED_SPIDER_EYES.eyeSize, SHARED_SPIDER_EYES.eyeSize);
      g.fillRect(SHARED_SPIDER_EYES.rightX, eyeTop, SHARED_SPIDER_EYES.eyeSize, SHARED_SPIDER_EYES.eyeSize);
      g.fillStyle = SHARED_SPIDER_EYES.pupilColor;
      g.fillRect(SHARED_SPIDER_EYES.leftX + SHARED_SPIDER_EYES.pupilInset, eyeTop + SHARED_SPIDER_EYES.pupilInset, SHARED_SPIDER_EYES.pupilSize, SHARED_SPIDER_EYES.pupilSize);
      g.fillRect(SHARED_SPIDER_EYES.rightX + SHARED_SPIDER_EYES.pupilInset, eyeTop + SHARED_SPIDER_EYES.pupilInset, SHARED_SPIDER_EYES.pupilSize, SHARED_SPIDER_EYES.pupilSize);
      g.strokeStyle = crashed ? "#66564a" : "#4a4743";
      g.lineWidth = 1.7;
      g.lineCap = "round";
      g.beginPath();
      if (firing) g.ellipse(0, -1.2, 2.1, 2.6, 0, 0, Math.PI * 2);
      else { g.moveTo(-3.5, -1.5); g.quadraticCurveTo(0, -1.15, 3.5, -1.5); }
      g.stroke();
      const markY = (webbing ? 10 : 9) + Math.max(0, appearance.bodyY - 3);
      g.fillStyle = crashed ? "#b65549" : appearance.accent;
      g.strokeStyle = crashed ? "#b65549" : appearance.accent;
      switch (appearance.mark) {
        case "cross":
          g.fillRect(-2.2, markY - 9, 4.4, 17);
          g.fillRect(-7, markY - 3.5, 14, 5);
          break;
        case "chevrons":
          g.lineWidth = 2.7;
          for (const offset of [-5, 2]) {
            g.beginPath(); g.moveTo(-6, markY + offset); g.lineTo(0, markY + offset + 4); g.lineTo(6, markY + offset); g.stroke();
          }
          break;
        case "stripe":
          g.fillRect(-2.3, markY - 10, 4.6, 19);
          break;
        case "spots":
          for (const [x, y, radius] of [[-6, -5, 2.8], [5, -7, 2.2], [0, 3, 3.1], [7, 5, 1.8]] as const) {
            g.beginPath(); g.arc(x, markY + y, radius, 0, Math.PI * 2); g.fill();
          }
          break;
        case "bands":
          g.fillRect(-9, markY - 7, 18, 3.5);
          g.fillStyle = crashed ? "#d8c7b4" : appearance.accentAlt;
          g.fillRect(-8, markY + 2, 16, 3.5);
          break;
        default:
          g.beginPath();
          g.moveTo(-5.2, markY - 5); g.lineTo(5.2, markY - 5); g.lineTo(1.45, markY);
          g.lineTo(5, markY + 5); g.lineTo(-5, markY + 5); g.lineTo(-1.45, markY);
          g.closePath();
          g.fill();
      }
      g.restore();
    };
    const drawSpider = () => {
      const pose = getSpiderPose();
      paintSpiderMotionEffects(ctx, pose, pose.x, pose.y);
      paintSpiderBase(ctx, pose, pose.x, pose.y);
      if (!pixelSpiderCtx) {
        paintSpiderOutline(ctx, pose, pose.x, pose.y);
        paintSpiderDetails(ctx, pose, pose.x, pose.y);
        return;
      }
      pixelSpiderCtx.setTransform(1, 0, 0, 1, 0, 0);
      pixelSpiderCtx.clearRect(0, 0, spiderLayerPixels, spiderLayerPixels);
      const pixelScale = spiderLayerPixels / spiderLayerSize;
      pixelSpiderCtx.setTransform(pixelScale, 0, 0, pixelScale, 0, 0);
      pixelSpiderCtx.imageSmoothingEnabled = false;
      paintSpiderOutline(pixelSpiderCtx, pose, spiderLayerSize / 2, spiderLayerSize / 2);
      ctx.save();
      ctx.imageSmoothingEnabled = false;
      ctx.drawImage(
        spiderLayer,
        pose.x - spiderLayerSize / 2,
        pose.y - spiderLayerSize / 2,
        spiderLayerSize,
        spiderLayerSize,
      );
      ctx.restore();
      paintSpiderDetails(ctx, pose, pose.x, pose.y);
    };
    const showGameOver = () => {
      if (modeRef.current === "over") return;
      modeRef.current = "over"; setMode("over");
      beep(92, .16);
      const currentScore = scoreRef.current;
      const storedBest = readStoredBest();
      const previousBest = Math.max(bestRef.current, storedBest);
      const nextBest = Math.max(currentScore, previousBest);
      setNewBest(currentScore > previousBest);
      bestRef.current = nextBest;
      localStorage.setItem(BEST_SCORE_KEY, String(nextBest));
      setBest(nextBest);
    };
    const crash = () => {
      if (modeRef.current !== "playing") return;
      releaseWeb();
      spider.vy = Math.max(120, spider.vy);
      modeRef.current = "falling";
      setMode("falling");
      fallTime = 0;
      beep(145, .14);
    };
    const trunkLeftAt = (x:number, y:number, seed:number) => x - 43 + Math.sin(y * .043 + seed) * 3.4;
    const trunkRightAt = (x:number, y:number, seed:number) => x + 43 + Math.sin(y * .039 + seed * 1.37) * 3.4;
    const pointInTrunk = (o:Obstacle, x:number, y:number) => {
      const top = o.gapY - o.gap / 2;
      const bottom = o.gapY + o.gap / 2;
      const betweenSides = x >= trunkLeftAt(o.x, y, o.seed) && x <= trunkRightAt(o.x, y, o.seed);
      const inTopBody = y <= top && betweenSides;
      const inBottomBody = y >= bottom && betweenSides;
      const inTopCap = ((x - o.x) / 46) ** 2 + ((y - top) / 9.5) ** 2 <= 1;
      const inBottomCap = ((x - o.x) / 46) ** 2 + ((y - bottom) / 9.5) ** 2 <= 1;
      return inTopBody || inBottomBody || inTopCap || inBottomCap;
    };
    const webEnd = () => {
      const pose = getSpiderPose();
      const attachment = getWebAttachmentGeometry();
      const origin = pointOnSpider(pose, attachment.x, attachment.y);
      const sx = origin.x;
      const sy = origin.y;
      const ex = Math.min(w - 4, sx + Math.max(205, w * .31));
      const ey = Math.max(4, sy - Math.max(215, h * .42));
      const dx = ex - sx;
      const dy = ey - sy;
      const steps = Math.max(1, Math.ceil(Math.hypot(dx, dy) / 2));
      let previousT = 0;
      for (let i = 1; i <= steps; i++) {
        const t = i / steps;
        const x = sx + dx * t;
        const y = sy + dy * t;
        const obstacle = obstacles.find((o) => pointInTrunk(o, x, y));
        if (obstacle) {
          let outside = previousT;
          let inside = t;
          for (let j = 0; j < 9; j++) {
            const middle = (outside + inside) / 2;
            const mx = sx + dx * middle;
            const my = sy + dy * middle;
            if (pointInTrunk(obstacle, mx, my)) inside = middle;
            else outside = middle;
          }
          return {
            sx,
            sy,
            x: sx + dx * inside,
            y: sy + dy * inside,
            attached: true,
            obstacle,
          };
        }
        previousT = t;
      }
      return { sx, sy, x: ex, y: ey, attached: false, obstacle: null };
    };
    function releaseWeb() {
      if (!holdRef.current) return;
      if (webAttached && webTarget?.obstacle) {
        const pose = getSpiderPose();
        const attachment = getWebAttachmentGeometry();
        const origin = pointOnSpider(pose, attachment.x, attachment.y);
        const anchorX = webTarget.obstacle.x + webTarget.anchorDx;
        const anchorY = webTarget.anchorY;
        const dx = origin.x - anchorX;
        const dy = origin.y - anchorY;
        const distanceToSpider = Math.max(1, Math.hypot(dx, dy));
        const traceLength = Math.min(84, Math.max(44, distanceToSpider * .23));
        const maxLife = 2.8;
        webTraces.push({
          obstacle: webTarget.obstacle,
          skinId: skinRef.current,
          anchorDx: webTarget.anchorDx,
          anchorY,
          freeDx: dx / distanceToSpider * traceLength,
          freeDy: dy / distanceToSpider * traceLength,
          life: maxLife,
          maxLife,
          phase: Math.random() * Math.PI * 2,
        });
        if (webTraces.length > 14) webTraces.splice(0, webTraces.length - 14);
      }
      holdRef.current = false;
      webTarget = null;
      webAttached = false;
      webShotAge = 1;
    }
    const drawStickyAnchor = (x:number, y:number) => {
      ctx.save();
      ctx.translate(x, y);
      ctx.strokeStyle = "rgba(239,242,235,.9)";
      ctx.fillStyle = "#c7ad72";
      ctx.lineWidth = 1.25;
      ctx.lineCap = "round";
      for (let i = 0; i < 6; i++) {
        const angle = i * Math.PI / 3;
        ctx.beginPath();
        ctx.moveTo(Math.cos(angle) * 2.4, Math.sin(angle) * 2.4);
        ctx.lineTo(Math.cos(angle) * 6.2, Math.sin(angle) * 6.2);
        ctx.stroke();
      }
      ctx.beginPath();
      ctx.arc(0, 0, 3.3, 0, Math.PI * 2);
      ctx.fill();
      ctx.restore();
    };
    const drawSpeciesAnchor = (pattern: WebVisualPattern, x:number, y:number, angle = 0) => {
      ctx.save();
      ctx.translate(x, y);
      ctx.rotate(angle);
      ctx.strokeStyle = "rgba(239,242,235,.92)";
      ctx.fillStyle = "#c7ad72";
      ctx.lineWidth = 1.2;
      ctx.lineCap = "round";
      if (pattern === "orb-radial") {
        for (let index = 0; index < 6; index++) {
          const spoke = index * Math.PI / 3;
          ctx.beginPath();
          ctx.moveTo(0, 0);
          ctx.lineTo(Math.cos(spoke) * 8, Math.sin(spoke) * 8);
          ctx.stroke();
        }
        ctx.beginPath();
        ctx.arc(0, 0, 4.5, 0, Math.PI * 2);
        ctx.stroke();
      } else if (pattern === "triangle-sling") {
        ctx.beginPath();
        ctx.moveTo(0, -7);
        ctx.lineTo(7, 6);
        ctx.lineTo(-7, 6);
        ctx.closePath();
        ctx.stroke();
        ctx.beginPath();
        ctx.moveTo(0, -7);
        ctx.lineTo(0, 6);
        ctx.stroke();
      } else if (pattern === "casting-net") {
        ctx.beginPath();
        ctx.rect(-6, -6, 12, 12);
        ctx.moveTo(-6, 0); ctx.lineTo(6, 0);
        ctx.moveTo(0, -6); ctx.lineTo(0, 6);
        ctx.stroke();
      } else if (pattern === "bolas-drop") {
        ctx.beginPath();
        ctx.moveTo(0, -4);
        ctx.quadraticCurveTo(5, 2, 1, 8);
        ctx.stroke();
        ctx.beginPath();
        ctx.arc(1, 9, 4.4, 0, Math.PI * 2);
        ctx.fill();
      } else if (pattern === "gumfoot-tangle") {
        for (const offset of [-4, 0, 4]) {
          ctx.beginPath();
          ctx.moveTo(offset * .35, -5);
          ctx.lineTo(offset, 6);
          ctx.stroke();
          ctx.beginPath();
          ctx.arc(offset, 7.5, 1.9, 0, Math.PI * 2);
          ctx.fill();
        }
      } else if (pattern === "safety-dragline") {
        ctx.beginPath();
        ctx.arc(0, 0, 3.2, 0, Math.PI * 2);
        ctx.stroke();
        ctx.beginPath();
        ctx.moveTo(-4, -4); ctx.lineTo(4, 4);
        ctx.moveTo(4, -4); ctx.lineTo(-4, 4);
        ctx.stroke();
      } else {
        ctx.restore();
        drawStickyAnchor(x, y);
        return;
      }
      ctx.restore();
    };
    const drawReleasedWeb = ({
      pattern,
      strandColor,
      accentColor,
      sx,
      sy,
      dx,
      dy,
      nx,
      ny,
      shotProgress,
      shotX,
      shotY,
    }: ReturnType<typeof getWebAnimationProfile> & {
      sx: number;
      sy: number;
      dx: number;
      dy: number;
      nx: number;
      ny: number;
      shotProgress: number;
      shotX: number;
      shotY: number;
    }) => {
      const angle = Math.atan2(dy, dx);
      const drawStrand = (offset:number, width:number, wave:number) => {
        ctx.strokeStyle = strandColor;
        ctx.lineWidth = width;
        ctx.beginPath();
        ctx.moveTo(sx + nx * offset, sy + ny * offset);
        for (let index = 1; index <= 12; index++) {
          const t = index / 12 * shotProgress;
          const wiggle = Math.sin(index * 2.7) * wave * shotProgress;
          ctx.lineTo(sx + dx * t + nx * (offset + wiggle), sy + dy * t + ny * (offset + wiggle));
        }
        ctx.stroke();
      };

      if (pattern === "safety-dragline") {
        drawStrand(-1.45, 1, .45);
        drawStrand(1.45, 1, .45);
        ctx.save();
        ctx.globalAlpha = .56;
        ctx.setLineDash([3, 4]);
        drawStrand(0, .8, .2);
        ctx.restore();
      } else if (pattern === "triangle-sling") {
        drawStrand(0, 1.55, .45);
        const spread = 16 * Math.sin(Math.min(1, shotProgress) * Math.PI * .5);
        ctx.strokeStyle = strandColor;
        ctx.lineWidth = 1.15;
        ctx.beginPath();
        ctx.moveTo(sx, sy);
        ctx.lineTo(shotX + nx * spread, shotY + ny * spread);
        ctx.lineTo(shotX - nx * spread, shotY - ny * spread);
        ctx.closePath();
        ctx.stroke();
        for (const t of [.34, .67]) {
          ctx.beginPath();
          ctx.moveTo(sx + dx * shotProgress * t + nx * spread * t, sy + dy * shotProgress * t + ny * spread * t);
          ctx.lineTo(sx + dx * shotProgress * t - nx * spread * t, sy + dy * shotProgress * t - ny * spread * t);
          ctx.stroke();
        }
      } else {
        drawStrand(0, pattern === "arcade-line" ? 2 : 1.45, pattern === "gumfoot-tangle" ? 2.1 : 1.1);
      }

      if (pattern === "orb-radial") {
        const radius = 4 + 13 * Math.min(1, shotProgress * 1.2);
        ctx.save();
        ctx.translate(shotX, shotY);
        ctx.rotate(angle);
        ctx.strokeStyle = strandColor;
        ctx.lineWidth = 1;
        for (let index = 0; index < 7; index++) {
          const spoke = index * Math.PI * 2 / 7;
          ctx.beginPath();
          ctx.moveTo(0, 0);
          ctx.lineTo(Math.cos(spoke) * radius, Math.sin(spoke) * radius);
          ctx.stroke();
        }
        for (const ring of [.45, .78]) {
          ctx.beginPath();
          ctx.arc(0, 0, radius * ring, 0, Math.PI * 2);
          ctx.stroke();
        }
        ctx.restore();
      } else if (pattern === "casting-net") {
        const size = 5 + 14 * Math.min(1, shotProgress * 1.25);
        ctx.save();
        ctx.translate(shotX, shotY);
        ctx.rotate(angle + Math.PI / 4);
        ctx.strokeStyle = strandColor;
        ctx.lineWidth = 1;
        ctx.strokeRect(-size / 2, -size / 2, size, size);
        for (const offset of [-.25, .25]) {
          ctx.beginPath();
          ctx.moveTo(-size / 2, size * offset);
          ctx.lineTo(size / 2, size * offset);
          ctx.moveTo(size * offset, -size / 2);
          ctx.lineTo(size * offset, size / 2);
          ctx.stroke();
        }
        ctx.restore();
      } else if (pattern === "bolas-drop") {
        const swing = Math.sin(last * .018) * .58;
        ctx.save();
        ctx.translate(shotX, shotY);
        ctx.rotate(angle + Math.PI / 2 + swing);
        ctx.strokeStyle = strandColor;
        ctx.lineWidth = 1.35;
        ctx.beginPath();
        ctx.moveTo(0, 0);
        ctx.quadraticCurveTo(5, 7, 0, 15);
        ctx.stroke();
        ctx.fillStyle = accentColor;
        ctx.beginPath();
        ctx.arc(0, 17, 5.2, 0, Math.PI * 2);
        ctx.fill();
        ctx.strokeStyle = "rgba(247,243,222,.86)";
        ctx.beginPath();
        ctx.arc(-1.4, 15.4, 1.8, 0, Math.PI * 2);
        ctx.stroke();
        ctx.restore();
      } else if (pattern === "gumfoot-tangle") {
        ctx.save();
        ctx.strokeStyle = strandColor;
        ctx.fillStyle = accentColor;
        ctx.lineWidth = 1;
        for (const t of [.48, .67, .84]) {
          const branchX = sx + dx * shotProgress * t;
          const branchY = sy + dy * shotProgress * t;
          const side = t === .67 ? -1 : 1;
          ctx.beginPath();
          ctx.moveTo(branchX, branchY);
          ctx.lineTo(branchX + nx * 8 * side, branchY + ny * 8 * side + 7);
          ctx.stroke();
          ctx.beginPath();
          ctx.arc(branchX + nx * 8 * side, branchY + ny * 8 * side + 8.5, 2.1, 0, Math.PI * 2);
          ctx.fill();
        }
        ctx.restore();
      }
    };
    const drawBodyWebWrap = (front:boolean) => {
      const pose = getSpiderPose();
      const attachment = getWebAttachmentGeometry();
      ctx.save();
      ctx.translate(pose.x, pose.y);
      ctx.rotate(pose.rotation);
      ctx.scale(pose.scaleX, pose.scaleY);
      ctx.strokeStyle = front ? "rgba(244,246,240,.96)" : "rgba(225,234,229,.58)";
      ctx.lineWidth = front ? 1.8 : 1.35;
      ctx.lineCap = "round";
      ctx.beginPath();
      ctx.ellipse(0, attachment.y, attachment.x, attachment.wrapY, 0, front ? 0 : Math.PI, front ? Math.PI : Math.PI * 2);
      ctx.stroke();
      if (front) {
        ctx.fillStyle = "rgba(244,246,240,.96)";
        ctx.beginPath();
        ctx.arc(attachment.x, attachment.y, 2.15, 0, Math.PI * 2);
        ctx.fill();
      }
      ctx.restore();
    };
    const drawWebTrace = (trace:WebTrace) => {
      const webProfile = getWebAnimationProfile(trace.skinId);
      const anchorX = trace.obstacle.x + trace.anchorDx;
      const anchorY = trace.anchorY;
      const age = trace.maxLife - trace.life;
      const recoil = .64 + .36 * Math.max(0, 1 - age / .24);
      const dx = trace.freeDx * recoil;
      const dy = trace.freeDy * recoil;
      const length = Math.max(1, Math.hypot(dx, dy));
      const nx = -dy / length;
      const ny = dx / length;
      const sway = Math.sin(trace.phase + age * 7) * 4;
      const endX = anchorX + dx;
      const endY = anchorY + dy;
      ctx.save();
      ctx.globalAlpha = Math.min(1, trace.life / .55) * .82;
      ctx.strokeStyle = webProfile.strandColor;
      ctx.lineWidth = webProfile.pattern === "safety-dragline" ? 1 : 1.55;
      ctx.lineCap = "round";
      ctx.beginPath();
      ctx.moveTo(anchorX, anchorY);
      ctx.quadraticCurveTo(anchorX + dx * .52 + nx * (7 + sway), anchorY + dy * .48 + ny * (7 + sway), endX, endY);
      ctx.stroke();
      ctx.save();
      ctx.translate(endX, endY);
      ctx.rotate(Math.atan2(dy, dx));
      ctx.beginPath();
      ctx.arc(0, 4.5, 4.5, -Math.PI * .62, Math.PI * .9);
      ctx.stroke();
      ctx.restore();
      drawSpeciesAnchor(webProfile.pattern, anchorX, anchorY, Math.atan2(dy, dx));
      ctx.restore();
    };
    const contactFromTarget = (target:WebAnchor): WebContact => {
      const pose = getSpiderPose();
      const attachment = getWebAttachmentGeometry();
      const origin = pointOnSpider(pose, attachment.x, attachment.y);
      return {
        sx: origin.x,
        sy: origin.y,
        x: target.obstacle ? target.obstacle.x + target.anchorDx : target.fixedX,
        y: target.obstacle ? target.anchorY : target.fixedY,
        attached: Boolean(target.obstacle),
        obstacle: target.obstacle,
      };
    };
    const loop = (now: number) => {
      const dt = Math.min(.034, (now - last) / 1000); last = now;
      if (modeRef.current === "playing") {
        const speed = worldSpeed();
        webShotAge = holdRef.current ? webShotAge + dt : 1;
        distance += speed * dt;
        spider.vy += (holdRef.current ? GAMEPLAY_RULES.physics.heldAcceleration : GAMEPLAY_RULES.physics.releasedAcceleration) * dt;
        spider.vy = Math.max(GAMEPLAY_RULES.physics.upwardSpeedCap, Math.min(GAMEPLAY_RULES.physics.downwardSpeedCap, spider.vy));
        spider.y += spider.vy * dt; spider.angle = Math.max(-.5, Math.min(.75, spider.vy / 620));
        for (const o of obstacles) {
          o.x -= speed * dt;
          if (!o.passed && o.x + 43 < spider.x - GAMEPLAY_RULES.collision.halfWidth) {
            o.passed = true;
            scoreRef.current += 1;
            setScore(scoreRef.current);
            rewardSound();
            for(let i=0;i<15;i++) particles.push({x:spider.x+20,y:spider.y,life:1,vx:35+Math.random()*95,vy:(Math.random()-.5)*150,color:i%3===0?"#c9ac68":i%2===0?"#8fa873":"#739c88",size:2+Math.random()*3});
          }
          const hitX = spider.x + GAMEPLAY_RULES.collision.halfWidth > o.x - 43 && spider.x - GAMEPLAY_RULES.collision.halfWidth < o.x + 43;
          if (hitX && (spider.y - GAMEPLAY_RULES.collision.halfHeight < o.gapY - o.gap/2 || spider.y + GAMEPLAY_RULES.collision.halfHeight > o.gapY + o.gap/2)) crash();
        }
        const first = obstacles[0];
        if (first && first.x < -80) {
          obstacles.shift();
          obstacles.push(makeObstacle(obstacles[obstacles.length - 1].x + obstacleSpacing()));
        }
        if (spider.y < GAMEPLAY_RULES.collision.ceiling || spider.y > h - GAMEPLAY_RULES.collision.groundClearance) crash();
        if (holdRef.current && spider.vy < -35 && Math.random() < .55) particles.push({x:spider.x+(Math.random()-.5)*22,y:spider.y+22,life:.8,vx:(Math.random()-.5)*28,vy:45+Math.random()*48,color:"#d5e5e0",size:1.5+Math.random()*2});
      } else if (modeRef.current === "falling") {
        fallTime += dt;
        spider.vy += GAMEPLAY_RULES.physics.crashAcceleration*dt; spider.y += spider.vy*dt; spider.angle=Math.min(1.45,spider.angle+2.5*dt);
        if(spider.y >= h-GAMEPLAY_RULES.collision.groundClearance){spider.y=h-GAMEPLAY_RULES.collision.groundClearance;showGameOver();}
      }
      for (const trace of webTraces) trace.life -= dt;
      webTraces = webTraces.filter((trace) => trace.life > 0 && trace.obstacle.x > -110 && obstacles.includes(trace.obstacle));
      if (webTarget?.obstacle && !obstacles.includes(webTarget.obstacle)) {
        webTarget = null;
        webAttached = false;
        if (holdRef.current) webShotAge = 0;
      }

      const sky=ctx.createLinearGradient(0,0,0,h);sky.addColorStop(0,"#7fbad2");sky.addColorStop(.58,"#c0d9d5");sky.addColorStop(1,"#e8dab5");ctx.fillStyle=sky;ctx.fillRect(0,0,w,h);
      const sun=ctx.createRadialGradient(w*.82,h*.15,4,w*.82,h*.15,52);sun.addColorStop(0,"rgba(248,222,112,.95)");sun.addColorStop(.42,"rgba(231,193,76,.62)");sun.addColorStop(1,"rgba(231,193,76,0)");ctx.fillStyle=sun;ctx.beginPath();ctx.arc(w*.82,h*.15,52,0,Math.PI*2);ctx.fill();
      for(let i=0;i<5;i++){const cloudX=((i*247-distance*.08)%(w+260)+w+260)%(w+260)-100;drawCloud(cloudX,65+(i%3)*72,.65+(i%2)*.23);}
      const farHillY=(x:number)=>h*.76-Math.sin((x+distance*.08)*.014)*34-Math.sin(x*.031)*14;
      ctx.fillStyle="#a3ba97";ctx.beginPath();ctx.moveTo(0,h*.78);for(let x=0;x<=w;x+=35)ctx.lineTo(x,farHillY(x));ctx.lineTo(w,h);ctx.lineTo(0,h);ctx.fill();
      const villageSpacing=Math.max(300,345*worldScale());
      const villageTravel=distance*.08;
      const firstVillage=Math.floor((villageTravel-170)/villageSpacing);
      const visibleVillages=Math.ceil(w/villageSpacing)+2;
      for(let i=0;i<visibleVillages;i++){
        const villageSeed=firstVillage+i;
        const villageX=villageSeed*villageSpacing-villageTravel+145;
        drawBackgroundVillage(villageX,farHillY,worldScale(),villageSeed);
      }
      ctx.fillStyle="#7aa386";ctx.beginPath();ctx.moveTo(0,h*.88);for(let x=0;x<=w;x+=30)ctx.lineTo(x,h*.87-Math.sin((x+distance*.16)*.022)*23);ctx.lineTo(w,h);ctx.lineTo(0,h);ctx.fill();
      ctx.fillStyle="#5b825f";ctx.fillRect(0,h-18,w,18);ctx.fillStyle="#8da972";ctx.fillRect(0,h-19,w,4);
      const drawTinyLeaf = (x:number,y:number,angle:number,color:string) => {
        ctx.save();ctx.translate(x,y);ctx.rotate(angle);ctx.fillStyle=color;ctx.beginPath();ctx.ellipse(0,0,7,3.8,0,0,Math.PI*2);ctx.fill();
        ctx.strokeStyle="rgba(220,226,193,.42)";ctx.lineWidth=.7;ctx.beginPath();ctx.moveTo(-4,0);ctx.lineTo(4,0);ctx.stroke();ctx.restore();
      };
      const drawTrunkSegment = (x:number,y1:number,y2:number,capY:number,seed:number) => {
        const leftAt=(y:number)=>trunkLeftAt(x,y,seed);
        const rightAt=(y:number)=>trunkRightAt(x,y,seed);
        ctx.fillStyle="#436f4d";
        ctx.fillRect(x-38,y1,76,y2-y1);
        const grad=ctx.createLinearGradient(x-48,0,x+48,0);grad.addColorStop(0,"#264d35");grad.addColorStop(.23,"#416f47");grad.addColorStop(.55,"#5c8654");grad.addColorStop(.82,"#396642");grad.addColorStop(1,"#1f4230");ctx.fillStyle=grad;
        ctx.beginPath();
        ctx.moveTo(leftAt(y1),y1);
        for(let y=y1+24;y<y2;y+=24)ctx.lineTo(leftAt(y),y);
        ctx.lineTo(leftAt(y2),y2);
        ctx.lineTo(rightAt(y2),y2);
        for(let y=y2-24;y>y1;y-=24)ctx.lineTo(rightAt(y),y);
        ctx.lineTo(rightAt(y1),y1);
        ctx.closePath();
        ctx.fill();
        ctx.strokeStyle="rgba(28,60,38,.48)";ctx.lineWidth=2;
        for(let lane=-25;lane<=25;lane+=17){
          const barkAt=(y:number)=>x+lane+Math.sin(y*.06+lane+seed)*3;
          ctx.beginPath();
          ctx.moveTo(barkAt(y1),y1);
          for(let y=y1+28;y<y2;y+=28)ctx.lineTo(barkAt(y),y);
          ctx.lineTo(barkAt(y2),y2);
          ctx.stroke();
        }
        ctx.fillStyle="#779a63";ctx.beginPath();ctx.ellipse(x,capY,46,9.5,0,0,Math.PI*2);ctx.fill();ctx.strokeStyle="#3b6542";ctx.lineWidth=2;ctx.beginPath();ctx.ellipse(x,capY,28,5,0,0,Math.PI*2);ctx.stroke();ctx.beginPath();ctx.ellipse(x,capY,12,2.5,0,0,Math.PI*2);ctx.stroke();
      };
      const drawTrunkWall = (o:Obstacle) => {
        const topEnd=o.gapY-o.gap/2,bottom=o.gapY+o.gap/2;
        const overhang=Math.max(72,h*.08);
        ctx.shadowColor="rgba(28,59,38,.22)";ctx.shadowBlur=8;drawTrunkSegment(o.x,-overhang,topEnd,topEnd,o.seed);drawTrunkSegment(o.x,bottom,h+overhang,bottom,o.seed);ctx.shadowBlur=0;ctx.shadowColor="transparent";
        const colors=["#4e7858","#6c885b","#839764"];
        for(let yy=24;yy<topEnd-12;yy+=37){const wave=Math.sin(last*.0012+yy*.09+o.seed);drawTinyLeaf(o.x-47,yy,-.55+wave*.12,colors[Math.floor(yy/37)%3]);if(Math.floor(yy/37)%2===0)drawTinyLeaf(o.x+47,yy+12,.55-wave*.12,colors[(Math.floor(yy/37)+1)%3]);}
        for(let yy=bottom+18;yy<h;yy+=37){const wave=Math.sin(last*.0012+yy*.09+o.seed);drawTinyLeaf(o.x+47,yy,.55+wave*.12,colors[Math.floor(yy/37)%3]);if(Math.floor(yy/37)%2===0)drawTinyLeaf(o.x-47,yy+12,-.55-wave*.12,colors[(Math.floor(yy/37)+1)%3]);}
        for(let i=-2;i<=2;i++){drawTinyLeaf(o.x+i*17,topEnd-10-Math.abs(i)*2,-.25+i*.14,colors[(i+5)%3]);drawTinyLeaf(o.x+i*17,bottom+10+Math.abs(i)*2,.25+i*.14,colors[(i+7)%3]);}
      };
      for (const o of obstacles) {
        drawTrunkWall(o);
      }
      ctx.shadowBlur=0;
      for (const trace of webTraces) drawWebTrace(trace);
      if (holdRef.current && modeRef.current === "playing") {
        if (!webTarget) {
          const hit = webEnd();
          webTarget = {
            obstacle: hit.obstacle,
            anchorDx: hit.obstacle ? hit.x - hit.obstacle.x : 0,
            anchorY: hit.y,
            fixedX: hit.x,
            fixedY: hit.y,
          };
          webAttached = false;
        }
        const end=contactFromTarget(webTarget), dx=end.x-end.sx, dy=end.y-end.sy, length=Math.max(1,Math.hypot(dx,dy)), nx=-dy/length, ny=dx/length;
        const shotProgress=Math.min(1,webShotAge/GAMEPLAY_RULES.animation.visibleWebTravelSeconds), shotX=end.sx+dx*shotProgress, shotY=end.sy+dy*shotProgress;
        const webProfile = getWebAnimationProfile(skinRef.current);
        if (end.attached && shotProgress > .98) webAttached = true;
        drawBodyWebWrap(false);
        drawReleasedWeb({ ...webProfile, sx: end.sx, sy: end.sy, dx, dy, nx, ny, shotProgress, shotX, shotY });
        if (webAttached) drawSpeciesAnchor(webProfile.pattern, end.x, end.y, Math.atan2(dy, dx));
        else {ctx.fillStyle="#f0f1eb";ctx.beginPath();ctx.arc(shotX,shotY,2.5,0,Math.PI*2);ctx.fill();}
        if(webShotAge<GAMEPLAY_RULES.animation.webFlashSeconds){const flash=1-webShotAge/GAMEPLAY_RULES.animation.webFlashSeconds;ctx.save();ctx.translate(end.sx,end.sy);ctx.rotate(Math.atan2(dy,dx));ctx.strokeStyle=`rgba(235,239,233,${flash*.82})`;ctx.lineWidth=1.6;ctx.beginPath();ctx.arc(0,0,5+(1-flash)*10,0,Math.PI*2);ctx.stroke();for(const a of [-.48,0,.48]){ctx.save();ctx.rotate(a);ctx.beginPath();ctx.moveTo(7,0);ctx.lineTo(13+flash*5,0);ctx.stroke();ctx.restore();}ctx.restore();}
      }
      particles=particles.filter(p=>p.life>0);for(const p of particles){p.life-=dt*2;p.x+=p.vx*dt;p.y+=p.vy*dt;ctx.globalAlpha=p.life;ctx.fillStyle=p.color??"#c7dad5";ctx.fillRect(p.x,p.y,p.size??3,p.size??3);}ctx.globalAlpha=1;
      drawSpider();
      if (holdRef.current && modeRef.current === "playing") drawBodyWebWrap(true);
      frameRef.current=requestAnimationFrame(loop);
    };
    const startWeb=()=>{
      if(!holdRef.current){
        webShotAge=0;
        webTarget=null;
        webAttached=false;
      }
      holdRef.current=true;
    };
    const down=(e:PointerEvent)=>{
      e.preventDefault();
      if(modeRef.current==="ready") beginRun();
      if(modeRef.current==="playing") startWeb();
    };
    const up=(e:PointerEvent)=>{e.preventDefault();releaseWeb();};
    const cancelHold=()=>{releaseWeb();};
    const keyDown=(e:KeyboardEvent)=>{if(e.code==="Space"||e.code==="ArrowUp"){e.preventDefault();if(modeRef.current==="ready")beginRun();if(modeRef.current==="playing")startWeb();}};
    const keyUp=(e:KeyboardEvent)=>{if(e.code==="Space"||e.code==="ArrowUp")releaseWeb();};
    resize();window.addEventListener("resize",resize);document.addEventListener("fullscreenchange",resize);canvas.addEventListener("pointerdown",down);window.addEventListener("pointerup",up);window.addEventListener("pointercancel",cancelHold);window.addEventListener("blur",cancelHold);document.addEventListener("visibilitychange",cancelHold);window.addEventListener("keydown",keyDown);window.addEventListener("keyup",keyUp);frameRef.current=requestAnimationFrame(loop);
    return()=>{resetGameRef.current=()=>undefined;cancelAnimationFrame(frameRef.current);window.removeEventListener("resize",resize);document.removeEventListener("fullscreenchange",resize);canvas.removeEventListener("pointerdown",down);window.removeEventListener("pointerup",up);window.removeEventListener("pointercancel",cancelHold);window.removeEventListener("blur",cancelHold);document.removeEventListener("visibilitychange",cancelHold);window.removeEventListener("keydown",keyDown);window.removeEventListener("keyup",keyUp);};
  }, [beep, beginRun, rewardSound]);

  const toggleSound = () => { soundRef.current=!soundRef.current;setSound(soundRef.current); };
  const fullscreen = async () => {
    try {
      if (document.fullscreenElement) {
        await document.exitFullscreen?.();
        return;
      }
      const shell = document.querySelector<HTMLElement>(".game-shell");
      if (shell?.requestFullscreen) await shell.requestFullscreen();
      else await document.documentElement.requestFullscreen?.();
    } catch {
      return;
    }
  };
  const shareChallenge = async (scoreToShare:number) => {
    const challengeScore = Math.max(0, Math.min(MAX_CHALLENGE_SCORE, Math.floor(scoreToShare)));
    const url = new URL(`${window.location.origin}${window.location.pathname}`);
    url.searchParams.set(CHALLENGE_PARAM, String(challengeScore));
    const shareUrl = url.toString();
    const text = `I scored ${challengeScore} in WebTug. Can you beat it?`;
    const copyLink = async () => {
      const copyValue = `${text}\n${shareUrl}`;
      if (navigator.clipboard?.writeText) {
        await navigator.clipboard.writeText(copyValue);
      } else {
        const textarea = document.createElement("textarea");
        textarea.value = copyValue;
        textarea.style.position = "fixed";
        textarea.style.opacity = "0";
        document.body.appendChild(textarea);
        textarea.focus();
        textarea.select();
        document.execCommand("copy");
        textarea.remove();
      }
      setShareNotice("CHALLENGE LINK COPIED!");
    };
    try {
      if (typeof navigator.share === "function") {
        await navigator.share({ title: "WebTug Challenge", text, url: shareUrl });
        setShareNotice("CHALLENGE SHARED!");
      } else {
        await copyLink();
      }
    } catch (error) {
      if (error instanceof DOMException && error.name === "AbortError") return;
      try {
        await copyLink();
      } catch {
        setShareNotice("COULD NOT SHARE — TRY AGAIN");
      }
    }
    window.setTimeout(() => setShareNotice(""), 2600);
  };
  const returnToMenu = () => {
    holdRef.current=false;
    resetGameRef.current();
    scoreRef.current=0;
    setScore(0);
    setNewBest(false);
    setChallengeTarget(null);
    const url = new URL(window.location.href);
    url.searchParams.delete(CHALLENGE_PARAM);
    window.history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`);
    modeRef.current="menu";
    setMode("menu");
  };
  const medal = score >= 40 ? "PLATINUM" : score >= 30 ? "GOLD" : score >= 20 ? "SILVER" : score >= 10 ? "BRONZE" : null;
  const challengeWon = challengeTarget !== null && score > challengeTarget;
  const challengeGoal = challengeTarget === null ? null : challengeTarget + 1;
  const currentSkin = getSpiderSkin(skinId);
  const currentWebProfile = WEB_ANIMATION_PROFILES[skinId];

  return <main className="game-shell">
    <header><Link className="game-home-link" href="/" aria-label="WebTug home"><GameLogo compact/></Link><div className="top-actions"><a className="game-info-link" href="/how-to-play" aria-label="How to play and game information">i</a><button onClick={toggleSound} aria-label="Toggle sound">{sound ? "♪" : "×"}</button><button onClick={fullscreen} aria-label="Toggle fullscreen">⛶</button></div></header>
    <section className="arena">
      <canvas ref={canvasRef} aria-label="WebTug game" />
      {(mode === "playing" || mode === "falling") && <div className="live-score" aria-label={`Score ${score}`}>{score}</div>}
      {challengeTarget !== null && (mode === "playing" || mode === "falling") && <div className={`challenge-hud${challengeWon ? " is-won" : ""}`}>
        <span>BEAT {challengeTarget}</span>
        <strong>{challengeWon ? "TARGET BEAT!" : `${score}/${challengeGoal}`}</strong>
      </div>}
      {routeChecked && mode === "menu" && <div className="overlay menu-overlay">
        <p className="eyebrow">A TINY SPIDER. A BIG FOREST.</p>
        <GameLogo heading/>
        <div className="menu-best">BEST SCORE <strong>{best}</strong></div>
        <p className="instructions">Hold to attach a web and climb.<br/>Release to drop. Thread every trunk gap.</p>
        <div className="skin-picker" role="group" aria-labelledby="spider-roster-heading">
          <strong id="spider-roster-heading" className="skin-picker-title">TAP A SPIDER PROFILE</strong>
          <div className="skin-avatar-grid">
            {SPIDER_SKINS.map((skin) => {
              const webProfile = WEB_ANIMATION_PROFILES[skin.id];
              return <button
                type="button"
                key={skin.id}
                className={skin.id === skinId ? "is-selected" : undefined}
                aria-pressed={skin.id === skinId}
                aria-label={`Choose ${skin.label}, ${webProfile.label}`}
                onClick={() => chooseSkin(skin.id)}
              >
                <SpiderAvatar skinId={skin.id}/>
                <span>{skin.label}</span>
                <small>{webProfile.label}</small>
              </button>;
            })}
          </div>
          <p className="selected-spider-profile"><strong>{currentSkin.scientificName}</strong><span>{currentWebProfile.releaseCue}</span></p>
          <small className="game-invariant">WEB VISUAL CHANGES · SAME SPIDER MOTION, EYES, WEIGHT, HITBOX &amp; CONTROLS</small>
          <a href="/spider-lab">LEARN THE REAL PHYSICS →</a>
        </div>
        <button className="play" onClick={enterReady}>PLAY<span>›</span></button>
        {best > 0 && <button className="invite-link" onClick={() => void shareChallenge(best)} aria-label={`Challenge a friend to beat score ${best}`}>INVITE · BEAT {best}<span>↗</span></button>}
        <small className="hint">HOLD SCREEN, MOUSE, OR SPACEBAR</small>
      </div>}
      {routeChecked && mode === "ready" && <div className="overlay ready-overlay">
        <p className="eyebrow">{challengeTarget !== null ? "FRIEND CHALLENGE" : "GET READY"}</p>
        <h1>HOLD &amp; RELEASE</h1>
        {challengeTarget !== null && <div className="challenge-card"><span>FRIEND SCORE</span><strong>{challengeTarget}</strong><small>SCORE {challengeGoal} TO WIN</small></div>}
        <div className="control-demo" aria-label={`${currentSkin.label} using the shared WebTug movement`}><span className="demo-web"/><span className="demo-spider">••</span></div>
        <p className="instructions">Hold to web upward.<br/>Release and gravity pulls you down.</p>
        <strong className="start-hint">PRESS AND HOLD TO START</strong>
      </div>}
      {routeChecked && mode === "over" && <div className="overlay game-over">
        <p className="eyebrow">{challengeTarget !== null ? (challengeWon ? "CHALLENGE COMPLETE" : "CHALLENGE MISSED") : "NICE TRY, LITTLE SPIDER"}</p><h1>{challengeWon ? "YOU BEAT IT!" : "GAME OVER"}</h1>
        {newBest && <div className="new-best">NEW BEST!</div>}
        {challengeTarget !== null && <div className={`challenge-result ${challengeWon ? "won" : "missed"}`}><span>TARGET {challengeTarget}</span><strong>{challengeWon ? `WON BY ${score-challengeTarget}` : `${Math.max(0,challengeGoal!-score)} MORE TO WIN`}</strong></div>}
        <div className="result-card"><div className={`medal ${medal ? medal.toLowerCase() : "empty"}`}>{medal ? "✦" : "—"}<small>{medal ?? "NO MEDAL"}</small></div><div className="result-numbers"><p><span>SCORE</span><strong>{score}</strong></p><p><span>BEST</span><strong>{best}</strong></p></div></div>
        <div className="menu-actions"><button className="play" onClick={enterReady}>RETRY<span>›</span></button><button className="invite-link" onClick={() => void shareChallenge(score)} aria-label={`Challenge a friend to beat score ${score}`}>CHALLENGE A FRIEND<span>↗</span></button><button className="secondary" onClick={returnToMenu}>MAIN MENU</button></div>
      </div>}
      <div className={`share-status${shareNotice ? " is-visible" : ""}`} role="status" aria-live="polite">{shareNotice}</div>
    </section>
    <footer>
      <span className="footer-controls">HOLD = WEB UP <span aria-hidden="true">•</span> RELEASE = DROP</span>
      <nav className="game-footer-links" aria-label="WebTug information">
        <a href="/about">ABOUT</a>
        <a href="/spider-lab">SPIDER LAB</a>
        <a href="/privacy">PRIVACY</a>
        <a href="/contact">CONTACT</a>
      </nav>
    </footer>
  </main>;
}
