<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Shadow of the Fell Crown</title>
  <style>
    :root {
      --gold: #d8b25c;
      --red: #9f2f2f;
      --blue: #56b7ff;
      --green: #6de28c;
      --panel: rgba(7, 10, 14, 0.78);
    }

    * { box-sizing: border-box; }

    html, body {
      width: 100%;
      height: 100%;
      margin: 0;
      overflow: hidden;
      background: #05070a;
      color: #eee;
      font-family: Georgia, "Times New Roman", serif;
      user-select: none;
    }

    canvas { display: block; }

    #hud {
      position: fixed;
      inset: 0;
      pointer-events: none;
      z-index: 10;
    }

    #bars {
      position: absolute;
      left: 24px;
      bottom: 24px;
      width: min(360px, calc(100vw - 48px));
    }

    .bar-label {
      display: flex;
      justify-content: space-between;
      margin: 8px 3px 4px;
      font-size: 14px;
      letter-spacing: 0.08em;
      text-shadow: 0 2px 3px #000;
    }

    .bar {
      height: 18px;
      border: 2px solid rgba(230, 220, 190, 0.55);
      background: rgba(0,0,0,.58);
      box-shadow: 0 3px 12px rgba(0,0,0,.55);
      overflow: hidden;
    }

    .fill {
      height: 100%;
      width: 100%;
      transition: width .15s linear;
    }

    #healthFill { background: linear-gradient(90deg, #651a1a, #df4545); }
    #manaFill { background: linear-gradient(90deg, #173b67, #45a9ff); }
    #staminaFill { background: linear-gradient(90deg, #245b30, #72dd85); }

    #topInfo {
      position: absolute;
      top: 18px;
      left: 50%;
      transform: translateX(-50%);
      min-width: 310px;
      padding: 10px 18px;
      text-align: center;
      border: 1px solid rgba(216,178,92,.5);
      background: linear-gradient(180deg, rgba(25,20,12,.86), rgba(7,8,11,.72));
      box-shadow: 0 8px 30px rgba(0,0,0,.45);
      letter-spacing: .06em;
    }

    #waveText {
      color: var(--gold);
      font-weight: bold;
      font-size: 19px;
    }

    #scoreText {
      margin-top: 4px;
      font-size: 13px;
      color: #ddd4bd;
    }

    #spellPanel {
      position: absolute;
      right: 22px;
      bottom: 22px;
      display: grid;
      gap: 8px;
      width: 260px;
    }

    .spell {
      display: grid;
      grid-template-columns: 35px 1fr auto;
      align-items: center;
      min-height: 46px;
      padding: 6px 9px;
      background: var(--panel);
      border: 1px solid rgba(255,255,255,.18);
      box-shadow: 0 5px 18px rgba(0,0,0,.35);
      opacity: .64;
      transition: .15s;
    }

    .spell.active {
      opacity: 1;
      border-color: var(--gold);
      transform: translateX(-6px);
      box-shadow: 0 0 18px rgba(216,178,92,.2);
    }

    .spell.cooling { filter: grayscale(.75); }

    .key {
      width: 28px;
      height: 28px;
      display: grid;
      place-items: center;
      border: 1px solid #ab9563;
      color: #f6df9d;
      background: #191712;
      font-weight: bold;
    }

    .spell-name { font-size: 14px; }
    .spell-cost { font-size: 12px; color: #83cfff; }

    #crosshair {
      position: absolute;
      left: 50%;
      top: 50%;
      width: 24px;
      height: 24px;
      transform: translate(-50%, -50%);
    }

    #crosshair::before,
    #crosshair::after {
      content: "";
      position: absolute;
      background: rgba(255,255,255,.9);
      box-shadow: 0 0 5px #000;
    }

    #crosshair::before {
      width: 2px;
      height: 18px;
      left: 11px;
      top: 3px;
    }

    #crosshair::after {
      height: 2px;
      width: 18px;
      top: 11px;
      left: 3px;
    }

    #damageFlash {
      position: absolute;
      inset: 0;
      background: radial-gradient(circle, transparent 35%, rgba(185,0,0,.65));
      opacity: 0;
      transition: opacity .08s;
    }

    #message {
      position: absolute;
      left: 50%;
      top: 31%;
      transform: translate(-50%, -50%);
      font-size: clamp(22px, 4vw, 52px);
      color: #f4deb1;
      text-shadow: 0 4px 8px #000;
      opacity: 0;
      transition: opacity .35s;
      text-align: center;
    }

    #bossBarWrap {
      display: none;
      position: absolute;
      top: 86px;
      left: 50%;
      transform: translateX(-50%);
      width: min(540px, 75vw);
      text-align: center;
    }

    #bossName {
      margin-bottom: 5px;
      color: #ffb26d;
      letter-spacing: .12em;
      font-weight: bold;
    }

    #bossBar {
      height: 16px;
      border: 2px solid #b87954;
      background: rgba(0,0,0,.68);
    }

    #bossFill {
      height: 100%;
      width: 100%;
      background: linear-gradient(90deg, #6c1111, #df5b28);
    }

    #startScreen,
    #gameOver {
      position: fixed;
      inset: 0;
      z-index: 30;
      display: grid;
      place-items: center;
      background:
        radial-gradient(circle at 50% 30%, rgba(76,58,26,.2), transparent 35%),
        linear-gradient(rgba(3,5,7,.86), rgba(2,3,5,.96));
    }

    #gameOver { display: none; }

    .card {
      width: min(720px, calc(100vw - 36px));
      padding: 36px;
      border: 1px solid rgba(216,178,92,.7);
      outline: 1px solid rgba(216,178,92,.15);
      outline-offset: 7px;
      text-align: center;
      background: rgba(9,10,13,.9);
      box-shadow: 0 18px 80px rgba(0,0,0,.75);
    }

    h1 {
      margin: 0 0 10px;
      color: #e8cf91;
      font-size: clamp(34px, 6vw, 72px);
      font-weight: normal;
      letter-spacing: .05em;
      text-shadow: 0 4px 18px rgba(216,178,92,.22);
    }

    .subtitle {
      color: #a99a7d;
      font-style: italic;
      margin-bottom: 24px;
    }

    .instructions {
      display: grid;
      grid-template-columns: repeat(2, 1fr);
      gap: 10px 24px;
      margin: 22px auto;
      max-width: 540px;
      text-align: left;
      color: #d5d0c5;
      font-family: system-ui, sans-serif;
      font-size: 14px;
    }

    button {
      margin-top: 18px;
      padding: 13px 28px;
      border: 1px solid #dfc178;
      background: linear-gradient(#4e3c1c, #21180b);
      color: #ffe7a4;
      font: bold 15px Georgia, serif;
      letter-spacing: .12em;
      cursor: pointer;
      box-shadow: 0 8px 24px rgba(0,0,0,.45);
    }

    button:hover { filter: brightness(1.25); }

    .lan-panel {
      max-width: 560px;
      margin: 20px auto 0;
      padding-top: 18px;
      border-top: 1px solid rgba(216,178,92,.25);
    }

    .lan-row {
      display: flex;
      gap: 9px;
      justify-content: center;
      flex-wrap: wrap;
    }

    .lan-row button { margin-top: 0; }

    .lan-row input {
      min-width: 235px;
      padding: 12px 14px;
      border: 1px solid rgba(216,178,92,.55);
      outline: none;
      background: rgba(0,0,0,.45);
      color: #fff0c0;
      font: 14px system-ui, sans-serif;
    }

    #networkStatus {
      min-height: 20px;
      margin: 12px 0 0;
      color: #bdb49f;
      font: 13px system-ui, sans-serif;
    }

    #networkBadge {
      position: absolute;
      top: 18px;
      right: 20px;
      padding: 7px 10px;
      border: 1px solid rgba(216,178,92,.4);
      background: rgba(5,7,10,.72);
      color: #d8c78f;
      font: 12px system-ui, sans-serif;
      letter-spacing: .05em;
    }

    #footerHint {
      position: absolute;
      left: 50%;
      bottom: 7px;
      transform: translateX(-50%);
      font: 12px system-ui, sans-serif;
      color: rgba(255,255,255,.45);
    }

    @media (max-width: 760px) {
      #spellPanel { width: 190px; right: 10px; bottom: 10px; }
      #bars { left: 10px; bottom: 10px; width: 210px; }
      .instructions { grid-template-columns: 1fr; }
      .card { padding: 24px; }
    }
  </style>
</head>
<body>
  <div id="startScreen">
    <div class="card">
      <h1>Shadow of the Fell Crown</h1>
      <div class="subtitle">A dark-fantasy arena of steel, sorcery, and endless foes</div>
      <div class="instructions">
        <div><b>WASD</b> — Move</div>
        <div><b>Mouse</b> — Aim</div>
        <div><b>Click</b> — Cast selected spell</div>
        <div><b>1–4 / Wheel</b> — Select spell</div>
        <div><b>Shift</b> — Sprint</div>
        <div><b>Space</b> — Jump</div>
        <div><b>Q</b> — Arcane shockwave</div>
        <div><b>Esc</b> — Release cursor</div>
      </div>
      <button id="startButton">PLAY OFFLINE</button>
      <div class="lan-panel">
        <div class="lan-row">
          <button id="hostButton">HOST LAN GAME</button>
          <input id="joinIp" value="127.0.0.1:8787" spellcheck="false" aria-label="Host IP and port" />
          <button id="joinButton">JOIN LAN GAME</button>
        </div>
        <div id="networkStatus">For LAN play, run <b>node fell-crown-lan-server.js</b> on the host computer.</div>
      </div>
    </div>
  </div>

  <div id="gameOver">
    <div class="card">
      <h1>The Shadow Prevails</h1>
      <div id="finalScore" class="subtitle"></div>
      <button id="restartButton">RISE AGAIN</button>
    </div>
  </div>

  <div id="hud">
    <div id="networkBadge">OFFLINE</div>
    <div id="damageFlash"></div>
    <div id="message"></div>

    <div id="topInfo">
      <div id="waveText">Wave 1</div>
      <div id="scoreText">Score 0 · Enemies 0</div>
    </div>

    <div id="bossBarWrap">
      <div id="bossName">FELL CAPTAIN</div>
      <div id="bossBar"><div id="bossFill"></div></div>
    </div>

    <div id="crosshair"></div>

    <div id="bars">
      <div class="bar-label"><span>VITALITY</span><span id="healthText">100 / 100</span></div>
      <div class="bar"><div class="fill" id="healthFill"></div></div>
      <div class="bar-label"><span>ARCANE POWER</span><span id="manaText">100 / 100</span></div>
      <div class="bar"><div class="fill" id="manaFill"></div></div>
      <div class="bar-label"><span>STAMINA</span><span id="staminaText">100 / 100</span></div>
      <div class="bar"><div class="fill" id="staminaFill"></div></div>
    </div>

    <div id="spellPanel">
      <div class="spell active" data-spell="0"><span class="key">1</span><span class="spell-name">Ember Bolt</span><span class="spell-cost">8</span></div>
      <div class="spell" data-spell="1"><span class="key">2</span><span class="spell-name">Frost Lance</span><span class="spell-cost">14</span></div>
      <div class="spell" data-spell="2"><span class="key">3</span><span class="spell-name">Storm Orb</span><span class="spell-cost">22</span></div>
      <div class="spell" data-spell="3"><span class="key">4</span><span class="spell-name">Sunfire</span><span class="spell-cost">35</span></div>
    </div>

    <div id="footerHint">Collect glowing crystals for health and mana. Survive as long as you can.</div>
  </div>

  <script type="module">
    import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.166.1/build/three.module.js";

    // ---------- Setup ----------
    const scene = new THREE.Scene();
    scene.background = new THREE.Color(0x060912);
    scene.fog = new THREE.FogExp2(0x070a10, 0.022);

    const camera = new THREE.PerspectiveCamera(76, innerWidth / innerHeight, 0.1, 400);
    camera.rotation.order = "YXZ";

    const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" });
    renderer.setSize(innerWidth, innerHeight);
    renderer.setPixelRatio(Math.min(devicePixelRatio, 1.75));
    renderer.shadowMap.enabled = true;
    renderer.shadowMap.type = THREE.PCFSoftShadowMap;
    renderer.outputColorSpace = THREE.SRGBColorSpace;
    document.body.prepend(renderer.domElement);

    const clock = new THREE.Clock();
    const raycaster = new THREE.Raycaster();
    const arenaRadius = 46;

    const hemi = new THREE.HemisphereLight(0x4b6285, 0x151008, 1.15);
    scene.add(hemi);

    const moon = new THREE.DirectionalLight(0xa8c7ff, 2.25);
    moon.position.set(-25, 40, -15);
    moon.castShadow = true;
    moon.shadow.mapSize.set(2048, 2048);
    moon.shadow.camera.left = -65;
    moon.shadow.camera.right = 65;
    moon.shadow.camera.top = 65;
    moon.shadow.camera.bottom = -65;
    scene.add(moon);

    // ---------- Utility ----------
    const rand = (a, b) => a + Math.random() * (b - a);
    const clamp = THREE.MathUtils.clamp;

    function cylinder(radiusTop, radiusBottom, height, color, segments = 8) {
      const m = new THREE.Mesh(
        new THREE.CylinderGeometry(radiusTop, radiusBottom, height, segments),
        new THREE.MeshStandardMaterial({ color, roughness: 0.85, metalness: 0.08 })
      );
      m.castShadow = true;
      m.receiveShadow = true;
      return m;
    }

    function addTorch(x, z, y = 2.8) {
      const holder = cylinder(.13, .16, 2.3, 0x33271e, 6);
      holder.position.set(x, y - 1.15, z);
      scene.add(holder);

      const flame = new THREE.Mesh(
        new THREE.SphereGeometry(.25, 8, 8),
        new THREE.MeshBasicMaterial({ color: 0xff8f32 })
      );
      flame.scale.y = 1.55;
      flame.position.set(x, y, z);
      scene.add(flame);

      const light = new THREE.PointLight(0xff7b2d, 3.2, 15, 2);
      light.position.set(x, y, z);
      scene.add(light);

      torches.push({ flame, light, seed: Math.random() * 10 });
    }

    function makeTextSprite(text, color = "#ffffff") {
      const canvas = document.createElement("canvas");
      canvas.width = 256;
      canvas.height = 64;
      const ctx = canvas.getContext("2d");
      ctx.font = "bold 30px Georgia";
      ctx.textAlign = "center";
      ctx.strokeStyle = "#000";
      ctx.lineWidth = 6;
      ctx.strokeText(text, 128, 42);
      ctx.fillStyle = color;
      ctx.fillText(text, 128, 42);
      const texture = new THREE.CanvasTexture(canvas);
      const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, transparent: true }));
      sprite.scale.set(4, 1, 1);
      return sprite;
    }


    // ---------- LAN multiplayer ----------
    const net = {
      mode: "offline",
      socket: null,
      id: null,
      connected: false,
      isHost: false,
      peers: new Map(),
      poseTimer: 0,
      snapshotTimer: 0
    };

    function setNetworkStatus(text, bad = false) {
      const el = document.getElementById("networkStatus");
      el.textContent = text;
      el.style.color = bad ? "#ff8b78" : "#b8dcae";
    }

    function setNetworkBadge(text) {
      document.getElementById("networkBadge").textContent = text;
    }

    function wsAddress(raw) {
      let value = raw.trim();
      if (!value) value = "127.0.0.1:8787";
      if (!/^wss?:\/\//i.test(value)) value = `ws://${value}`;
      return value;
    }

    function netSend(message) {
      if (net.socket?.readyState === WebSocket.OPEN) {
        net.socket.send(JSON.stringify(message));
      }
    }

    function makeRemotePlayer(id) {
      const group = new THREE.Group();

      const robe = cylinder(.42, .62, 1.45, 0x39485f, 8);
      robe.position.y = .8;
      group.add(robe);

      const head = new THREE.Mesh(
        new THREE.SphereGeometry(.34, 10, 8),
        new THREE.MeshStandardMaterial({ color: 0xc6a27e, roughness: .8 })
      );
      head.position.y = 1.75;
      head.castShadow = true;
      group.add(head);

      const hat = new THREE.Mesh(
        new THREE.ConeGeometry(.48, 1.2, 10),
        new THREE.MeshStandardMaterial({ color: 0x1c2534, roughness: .9 })
      );
      hat.position.y = 2.38;
      hat.rotation.z = -.08;
      group.add(hat);

      const staff = new THREE.Mesh(
        new THREE.CylinderGeometry(.045, .06, 2.15, 7),
        new THREE.MeshStandardMaterial({ color: 0x513820, roughness: .9 })
      );
      staff.position.set(.62, 1.03, 0);
      staff.rotation.z = -.15;
      group.add(staff);

      const crystal = new THREE.Mesh(
        new THREE.OctahedronGeometry(.16),
        new THREE.MeshBasicMaterial({ color: 0x70cfff })
      );
      crystal.position.set(.78, 2.08, 0);
      group.add(crystal);

      group.position.set(0, 0, 12);
      scene.add(group);
      const peer = {
        id,
        group,
        targetPosition: group.position.clone(),
        targetYaw: 0
      };
      net.peers.set(id, peer);
      return peer;
    }

    function removeRemotePlayer(id) {
      const peer = net.peers.get(id);
      if (!peer) return;
      scene.remove(peer.group);
      net.peers.delete(id);
    }

    function applyWorldSnapshot(data) {
      if (net.isHost) return;

      wave = data.wave;
      score = data.score;
      kills = data.kills;

      const incoming = new Set();
      for (const state of data.enemies || []) {
        incoming.add(state.id);
        let enemy = enemies.find(e => e.networkId === state.id);
        if (!enemy) enemy = createEnemy(state.type, state.x, state.z, state.id);
        enemy.group.position.set(state.x, state.y || 0, state.z);
        enemy.group.rotation.y = state.rotY || 0;
        enemy.hp = state.hp;
        enemy.maxHp = state.maxHp;

        if (enemy.type === "boss") {
          boss = enemy;
          document.getElementById("bossBarWrap").style.display = "block";
          document.getElementById("bossFill").style.width =
            `${Math.max(0, enemy.hp / enemy.maxHp * 100)}%`;
        }
      }

      for (const enemy of [...enemies]) {
        if (!incoming.has(enemy.networkId)) {
          if (enemy === boss) {
            boss = null;
            document.getElementById("bossBarWrap").style.display = "none";
          }
          scene.remove(enemy.group);
          enemies.splice(enemies.indexOf(enemy), 1);
        }
      }
      updateHUD();
    }

    function broadcastWorldSnapshot() {
      netSend({
        type: "snapshot",
        wave,
        score,
        kills,
        enemies: enemies.map(e => ({
          id: e.networkId,
          type: e.type,
          x: e.group.position.x,
          y: e.group.position.y,
          z: e.group.position.z,
          rotY: e.group.rotation.y,
          hp: e.hp,
          maxHp: e.maxHp
        }))
      });
    }

    function spawnSpellProjectile(spellIndex, origin, direction, cosmetic = false) {
      const s = spells[spellIndex];
      const dir = direction.clone().normalize();
      const mesh = new THREE.Mesh(
        new THREE.SphereGeometry(s.radius, 12, 10),
        new THREE.MeshBasicMaterial({ color: s.color })
      );
      mesh.position.copy(origin);
      scene.add(mesh);

      const light = new THREE.PointLight(s.color, spellIndex === 3 ? 5 : 3, spellIndex === 3 ? 12 : 7);
      mesh.add(light);
      if (s.kind === "frost") mesh.scale.set(.55, .55, 2.2);

      projectiles.push({
        mesh,
        velocity: dir.multiplyScalar(s.speed),
        life: 3.2,
        spell: s,
        cosmetic
      });
      spawnBurst(origin, s.color, spellIndex === 3 ? 16 : 6);
    }

    function handleNetworkMessage(message) {
      if (message.type === "welcome") {
        net.id = message.id;
        return;
      }

      if (message.type === "host-accepted") {
        net.connected = true;
        net.isHost = true;
        net.mode = "host";
        setNetworkStatus("Hosting successfully. Other players can join using this computer's LAN IP.");
        setNetworkBadge("LAN HOST");
        beginGame();
        return;
      }

      if (message.type === "join-accepted") {
        net.connected = true;
        net.isHost = false;
        net.mode = "client";
        setNetworkStatus("Connected to the host.");
        setNetworkBadge("LAN CLIENT");
        beginGame();
        return;
      }

      if (message.type === "error") {
        setNetworkStatus(message.message || "Connection failed.", true);
        return;
      }

      if (message.type === "pose" && message.id !== net.id) {
        const peer = net.peers.get(message.id) || makeRemotePlayer(message.id);
        peer.targetPosition.set(message.x, 0, message.z);
        peer.targetYaw = message.yaw;
        return;
      }

      if (message.type === "peer-left") {
        removeRemotePlayer(message.id);
        return;
      }

      if (message.type === "cast" && message.id !== net.id) {
        const origin = new THREE.Vector3(message.origin.x, message.origin.y, message.origin.z);
        const direction = new THREE.Vector3(message.direction.x, message.direction.y, message.direction.z);
        spawnSpellProjectile(message.spellIndex, origin, direction, !net.isHost);
        return;
      }

      if (message.type === "snapshot") {
        applyWorldSnapshot(message);
        return;
      }

      if (message.type === "host-left") {
        setNetworkBadge("HOST DISCONNECTED");
        showMessage("THE HOST HAS LEFT", 4);
      }
    }

    function connectLan(asHost) {
      if (net.socket) {
        try { net.socket.close(); } catch {}
      }

      const raw = asHost ? "127.0.0.1:8787" : document.getElementById("joinIp").value;
      const address = wsAddress(raw);
      setNetworkStatus(`Connecting to ${address}...`);

      const socket = new WebSocket(address);
      net.socket = socket;

      socket.addEventListener("open", () => {
        netSend({ type: asHost ? "claim-host" : "join" });
      });

      socket.addEventListener("message", event => {
        try { handleNetworkMessage(JSON.parse(event.data)); }
        catch (error) { console.warn("Bad LAN packet", error); }
      });

      socket.addEventListener("error", () => {
        setNetworkStatus(
          asHost
            ? "Could not reach the LAN server. Run: node fell-crown-lan-server.js"
            : "Could not connect. Check the IP, port, and that the host server is running.",
          true
        );
      });

      socket.addEventListener("close", () => {
        if (net.connected && !gameEnded) {
          setNetworkBadge("LAN DISCONNECTED");
        }
        net.connected = false;
      });
    }

    function updateNetworking(dt) {
      if (!net.connected) return;

      net.poseTimer += dt;
      if (net.poseTimer >= .08) {
        net.poseTimer = 0;
        netSend({
          type: "pose",
          x: player.position.x,
          y: player.position.y,
          z: player.position.z,
          yaw: player.yaw
        });
      }

      if (net.isHost) {
        net.snapshotTimer += dt;
        if (net.snapshotTimer >= .1) {
          net.snapshotTimer = 0;
          broadcastWorldSnapshot();
        }
      }

      for (const peer of net.peers.values()) {
        peer.group.position.lerp(peer.targetPosition, 1 - Math.exp(-14 * dt));
        let delta = peer.targetYaw - peer.group.rotation.y;
        delta = Math.atan2(Math.sin(delta), Math.cos(delta));
        peer.group.rotation.y += delta * (1 - Math.exp(-14 * dt));
      }
    }

    // ---------- World ----------
    const torches = [];
    const obstacles = [];

    const ground = new THREE.Mesh(
      new THREE.CircleGeometry(arenaRadius + 5, 72),
      new THREE.MeshStandardMaterial({ color: 0x171a18, roughness: .96, metalness: 0 })
    );
    ground.rotation.x = -Math.PI / 2;
    ground.receiveShadow = true;
    scene.add(ground);

    // Stone rings.
    for (let r = 8; r < arenaRadius; r += 7) {
      const ring = new THREE.Mesh(
        new THREE.RingGeometry(r - .08, r + .08, 72),
        new THREE.MeshBasicMaterial({ color: 0x30352f, transparent: true, opacity: .55, side: THREE.DoubleSide })
      );
      ring.rotation.x = -Math.PI / 2;
      ring.position.y = .012;
      scene.add(ring);
    }

    // Fortress wall.
    const wallMat = new THREE.MeshStandardMaterial({ color: 0x252931, roughness: .9 });
    for (let i = 0; i < 36; i++) {
      const a = i / 36 * Math.PI * 2;
      const radius = arenaRadius + 2;
      const wall = new THREE.Mesh(new THREE.BoxGeometry(8.6, 8, 3.2), wallMat);
      wall.position.set(Math.cos(a) * radius, 4, Math.sin(a) * radius);
      wall.rotation.y = -a;
      wall.castShadow = true;
      wall.receiveShadow = true;
      scene.add(wall);

      if (i % 4 === 0) {
        const tower = cylinder(3.2, 3.8, 13, 0x20252c, 10);
        tower.position.set(Math.cos(a) * radius, 6.5, Math.sin(a) * radius);
        scene.add(tower);

        for (let c = 0; c < 6; c++) {
          const crenel = new THREE.Mesh(new THREE.BoxGeometry(1.2, 1.8, 1.2), wallMat);
          const ca = c / 6 * Math.PI * 2;
          crenel.position.set(
            Math.cos(a) * radius + Math.cos(ca) * 2.8,
            13.9,
            Math.sin(a) * radius + Math.sin(ca) * 2.8
          );
          crenel.castShadow = true;
          scene.add(crenel);
        }
      }
    }

    // Central ruin and scattered cover.
    const altar = new THREE.Group();
    const altarBase = cylinder(4.3, 5.2, 1.2, 0x34383b, 8);
    altarBase.position.y = .6;
    altar.add(altarBase);
    const altarTop = cylinder(2, 2.8, 2.2, 0x282c32, 8);
    altarTop.position.y = 1.9;
    altar.add(altarTop);
    const rune = new THREE.Mesh(
      new THREE.TorusGeometry(1.15, .09, 10, 30),
      new THREE.MeshBasicMaterial({ color: 0x7b1cff })
    );
    rune.rotation.x = Math.PI / 2;
    rune.position.y = 3.05;
    altar.add(rune);
    scene.add(altar);
    obstacles.push({ x: 0, z: 0, radius: 5.4 });

    for (let i = 0; i < 14; i++) {
      const a = i / 14 * Math.PI * 2 + rand(-.18, .18);
      const r = rand(13, 34);
      const h = rand(3, 8);
      const pillar = cylinder(rand(.75, 1.2), rand(.95, 1.5), h, 0x30343a, 7);
      pillar.position.set(Math.cos(a) * r, h / 2, Math.sin(a) * r);
      pillar.rotation.z = rand(-.08, .08);
      scene.add(pillar);
      obstacles.push({ x: pillar.position.x, z: pillar.position.z, radius: 1.5 });

      if (i % 2 === 0) addTorch(Math.cos(a) * (r + 2), Math.sin(a) * (r + 2), 3.5);
    }

    // Distant mountains.
    for (let i = 0; i < 24; i++) {
      const a = i / 24 * Math.PI * 2;
      const r = rand(75, 110);
      const mountain = new THREE.Mesh(
        new THREE.ConeGeometry(rand(9, 20), rand(25, 55), 5),
        new THREE.MeshStandardMaterial({ color: 0x10141b, roughness: 1 })
      );
      mountain.position.set(Math.cos(a) * r, mountain.geometry.parameters.height / 2 - 5, Math.sin(a) * r);
      mountain.rotation.y = rand(0, Math.PI);
      scene.add(mountain);
    }

    // Stars.
    const starGeo = new THREE.BufferGeometry();
    const stars = [];
    for (let i = 0; i < 900; i++) {
      const r = rand(80, 180);
      const theta = rand(0, Math.PI * 2);
      const phi = rand(0.05, 1.35);
      stars.push(
        Math.sin(phi) * Math.cos(theta) * r,
        Math.cos(phi) * r + 30,
        Math.sin(phi) * Math.sin(theta) * r
      );
    }
    starGeo.setAttribute("position", new THREE.Float32BufferAttribute(stars, 3));
    scene.add(new THREE.Points(starGeo, new THREE.PointsMaterial({ color: 0x9fb8df, size: .35 })));

    // ---------- Player ----------
    const player = {
      position: new THREE.Vector3(0, 1.7, 15),
      velocity: new THREE.Vector3(),
      yaw: 0,
      pitch: 0,
      health: 100,
      maxHealth: 100,
      mana: 100,
      maxMana: 100,
      stamina: 100,
      maxStamina: 100,
      grounded: true,
      radius: .65,
      shockwaveCooldown: 0
    };

    camera.position.copy(player.position);

    const keys = {};
    let locked = false;
    let running = false;
    let gameEnded = false;

    addEventListener("keydown", e => {
      keys[e.code] = true;
      if (["Digit1","Digit2","Digit3","Digit4"].includes(e.code)) {
        selectedSpell = Number(e.code.slice(-1)) - 1;
        refreshSpellUI();
      }
      if (e.code === "KeyQ") castShockwave();
      if (e.code === "Space") e.preventDefault();
    });
    addEventListener("keyup", e => keys[e.code] = false);

    document.addEventListener("pointerlockchange", () => {
      locked = document.pointerLockElement === renderer.domElement;
    });

    document.addEventListener("mousemove", e => {
      if (!locked || gameEnded) return;
      player.yaw -= e.movementX * .0022;
      player.pitch -= e.movementY * .0022;
      player.pitch = clamp(player.pitch, -1.48, 1.48);
    });

    renderer.domElement.addEventListener("click", () => {
      if (!running || gameEnded) return;
      if (!locked) renderer.domElement.requestPointerLock();
      else castSelectedSpell();
    });

    renderer.domElement.addEventListener("wheel", e => {
      if (!running) return;
      selectedSpell = (selectedSpell + (e.deltaY > 0 ? 1 : 3)) % 4;
      refreshSpellUI();
    }, { passive: true });

    // ---------- Enemies ----------
    const enemies = [];
    const enemyProjectiles = [];
    const pickups = [];
    let wave = 0;
    let score = 0;
    let kills = 0;
    let wavePending = false;
    let boss = null;

    function createEnemy(type, x, z, networkId = null) {
      const group = new THREE.Group();
      let hp, speed, damage, scale, color, name;

      if (type === "orc") {
        hp = 48; speed = 3.2; damage = 9; scale = 1; color = 0x536b3d; name = "Raider";
      } else if (type === "wraith") {
        hp = 70; speed = 2.55; damage = 13; scale = 1.15; color = 0x4d5b78; name = "Wraith";
      } else if (type === "brute") {
        hp = 135; speed = 1.8; damage = 20; scale = 1.45; color = 0x6c4b3b; name = "Uruk Brute";
      } else {
        hp = 650 + wave * 60; speed = 1.65; damage = 27; scale = 2.1; color = 0x782f20; name = "Fell Captain";
      }

      const body = cylinder(.55 * scale, .75 * scale, 1.7 * scale, color, 7);
      body.position.y = 1.05 * scale;
      group.add(body);

      const head = new THREE.Mesh(
        new THREE.SphereGeometry(.48 * scale, 10, 8),
        new THREE.MeshStandardMaterial({ color: type === "wraith" ? 0x24344a : 0x6d7650, roughness: .8 })
      );
      head.position.y = 2.15 * scale;
      head.castShadow = true;
      group.add(head);

      const eyes = [];
      for (const side of [-1, 1]) {
        const eye = new THREE.Mesh(
          new THREE.SphereGeometry(.055 * scale, 6, 6),
          new THREE.MeshBasicMaterial({ color: type === "wraith" ? 0x73c6ff : 0xff3d1f })
        );
        eye.position.set(.18 * side * scale, 2.23 * scale, .43 * scale);
        group.add(eye);
        eyes.push(eye);
      }

      const weapon = new THREE.Mesh(
        new THREE.BoxGeometry(.14 * scale, 1.55 * scale, .14 * scale),
        new THREE.MeshStandardMaterial({ color: 0x6f7378, metalness: .65, roughness: .35 })
      );
      weapon.position.set(.85 * scale, 1.2 * scale, 0);
      weapon.rotation.z = -.28;
      weapon.castShadow = true;
      group.add(weapon);

      if (type === "wraith") {
        const cloak = new THREE.Mesh(
          new THREE.ConeGeometry(.92 * scale, 2.3 * scale, 8, 1, true),
          new THREE.MeshStandardMaterial({ color: 0x18202d, transparent: true, opacity: .8, side: THREE.DoubleSide })
        );
        cloak.position.y = .9 * scale;
        group.add(cloak);
      }

      if (type === "boss") {
        const horns = [];
        for (const side of [-1, 1]) {
          const horn = new THREE.Mesh(
            new THREE.ConeGeometry(.14 * scale, .75 * scale, 7),
            new THREE.MeshStandardMaterial({ color: 0x171717 })
          );
          horn.position.set(.38 * side * scale, 2.62 * scale, 0);
          horn.rotation.z = side * -.5;
          group.add(horn);
          horns.push(horn);
        }
        const label = makeTextSprite("FELL CAPTAIN", "#ff9b62");
        label.position.y = 3.35 * scale;
        group.add(label);
      }

      group.position.set(x, 0, z);
      scene.add(group);

      const enemy = {
        group, type, hp, maxHp: hp, speed, damage, scale, name,
        networkId: networkId || `enemy-${Date.now()}-${Math.random().toString(36).slice(2)}`,
        radius: .72 * scale,
        attackCooldown: rand(.4, 1.1),
        rangedCooldown: rand(1.5, 3),
        hitFlash: 0,
        slow: 0,
        dead: false,
        bob: Math.random() * Math.PI * 2
      };
      enemies.push(enemy);

      if (type === "boss") {
        boss = enemy;
        document.getElementById("bossBarWrap").style.display = "block";
        document.getElementById("bossName").textContent = name.toUpperCase();
      }
      return enemy;
    }

    function randomSpawnPoint() {
      const a = Math.random() * Math.PI * 2;
      const r = rand(35, 42);
      return { x: Math.cos(a) * r, z: Math.sin(a) * r };
    }

    function startWave() {
      wave++;
      wavePending = false;
      showMessage(wave % 5 === 0 ? `WAVE ${wave}\nTHE FELL CAPTAIN COMES` : `WAVE ${wave}`, 2.2);

      const count = 4 + Math.floor(wave * 1.55);
      for (let i = 0; i < count; i++) {
        setTimeout(() => {
          if (gameEnded) return;
          const p = randomSpawnPoint();
          let type = "orc";
          const roll = Math.random();
          if (wave >= 3 && roll < .22) type = "wraith";
          if (wave >= 4 && roll > .82) type = "brute";
          createEnemy(type, p.x, p.z);
        }, i * 220);
      }

      if (wave % 5 === 0) {
        setTimeout(() => {
          if (gameEnded) return;
          const p = randomSpawnPoint();
          createEnemy("boss", p.x, p.z);
        }, count * 220 + 500);
      }
      updateHUD();
    }

    function damageEnemy(enemy, amount, impact, kind = "fire") {
      if (!enemy || enemy.dead) return;
      enemy.hp -= amount;
      enemy.hitFlash = .11;

      if (kind === "frost") enemy.slow = 2.4;

      spawnBurst(impact || enemy.group.position.clone().add(new THREE.Vector3(0, 1.3, 0)),
        kind === "frost" ? 0x73d8ff : kind === "lightning" ? 0xb58cff : 0xff6a2f,
        kind === "sun" ? 22 : 9
      );

      if (enemy === boss) {
        document.getElementById("bossFill").style.width = `${Math.max(0, enemy.hp / enemy.maxHp * 100)}%`;
      }

      if (enemy.hp <= 0) killEnemy(enemy);
    }

    function killEnemy(enemy) {
      enemy.dead = true;
      score += Math.round(enemy.maxHp * (enemy.type === "boss" ? 3 : 1));
      kills++;
      if (enemy.type === "boss") {
        showMessage("THE CAPTAIN HAS FALLEN", 2.5);
        boss = null;
        document.getElementById("bossBarWrap").style.display = "none";
      }

      spawnBurst(enemy.group.position.clone().add(new THREE.Vector3(0, enemy.scale, 0)), 0xff4c24, 30);
      scene.remove(enemy.group);

      if (Math.random() < .24 || enemy.type === "boss") {
        spawnPickup(enemy.group.position.x, enemy.group.position.z, Math.random() < .48 ? "health" : "mana");
      }

      const index = enemies.indexOf(enemy);
      if (index >= 0) enemies.splice(index, 1);
      updateHUD();

      if (enemies.length === 0 && !wavePending) {
        wavePending = true;
        setTimeout(() => {
          if (!gameEnded && enemies.length === 0) startWave();
          else wavePending = false;
        }, 2200);
      }
    }

    // ---------- Pickups ----------
    function spawnPickup(x, z, type) {
      const group = new THREE.Group();
      const color = type === "health" ? 0x52ff78 : 0x4dc3ff;
      const crystal = new THREE.Mesh(
        new THREE.OctahedronGeometry(.48),
        new THREE.MeshStandardMaterial({ color, emissive: color, emissiveIntensity: 1.35, roughness: .2 })
      );
      crystal.castShadow = true;
      group.add(crystal);

      const ring = new THREE.Mesh(
        new THREE.TorusGeometry(.68, .055, 8, 24),
        new THREE.MeshBasicMaterial({ color, transparent: true, opacity: .75 })
      );
      ring.rotation.x = Math.PI / 2;
      group.add(ring);

      const light = new THREE.PointLight(color, 2.4, 8);
      group.add(light);

      group.position.set(x, .8, z);
      scene.add(group);
      pickups.push({ group, type, life: 18, seed: Math.random() * 10 });
    }

    function collectPickup(p) {
      if (p.type === "health") {
        player.health = Math.min(player.maxHealth, player.health + 32);
        showMessage("+32 VITALITY", 1);
      } else {
        player.mana = Math.min(player.maxMana, player.mana + 45);
        showMessage("+45 ARCANE POWER", 1);
      }
      scene.remove(p.group);
      pickups.splice(pickups.indexOf(p), 1);
      updateHUD();
    }

    // ---------- Magic ----------
    const projectiles = [];
    const particles = [];
    let selectedSpell = 0;

    const spells = [
      { name: "Ember Bolt", cost: 8, cooldown: .22, damage: 30, speed: 36, radius: .18, color: 0xff5a21, kind: "fire" },
      { name: "Frost Lance", cost: 14, cooldown: .52, damage: 48, speed: 48, radius: .14, color: 0x6edcff, kind: "frost" },
      { name: "Storm Orb", cost: 22, cooldown: .95, damage: 72, speed: 24, radius: .34, color: 0x956cff, kind: "lightning", splash: 3.8 },
      { name: "Sunfire", cost: 35, cooldown: 1.7, damage: 115, speed: 31, radius: .48, color: 0xffc63d, kind: "sun", splash: 5.3 }
    ];
    const cooldowns = [0, 0, 0, 0];

    function refreshSpellUI() {
      document.querySelectorAll(".spell").forEach((el, i) => {
        el.classList.toggle("active", i === selectedSpell);
      });
    }

    function castSelectedSpell() {
      const s = spells[selectedSpell];
      if (cooldowns[selectedSpell] > 0 || player.mana < s.cost || gameEnded) return;
      player.mana -= s.cost;
      cooldowns[selectedSpell] = s.cooldown;

      const dir = new THREE.Vector3(0, 0, -1).applyEuler(camera.rotation).normalize();
      const origin = camera.position.clone().add(dir.clone().multiplyScalar(.8));
      origin.y -= .08;

      const isLanClient = net.connected && !net.isHost;
      spawnSpellProjectile(selectedSpell, origin, dir, isLanClient);

      if (net.connected) {
        netSend({
          type: "cast",
          spellIndex: selectedSpell,
          origin: { x: origin.x, y: origin.y, z: origin.z },
          direction: { x: dir.x, y: dir.y, z: dir.z }
        });
      }

      updateHUD();
    }

    function castShockwave() {
      if (!running || !locked || player.shockwaveCooldown > 0 || player.mana < 45 || gameEnded) return;
      player.mana -= 45;
      player.shockwaveCooldown = 7;

      const ring = new THREE.Mesh(
        new THREE.RingGeometry(.5, .75, 48),
        new THREE.MeshBasicMaterial({ color: 0xd8b9ff, transparent: true, opacity: .9, side: THREE.DoubleSide })
      );
      ring.rotation.x = -Math.PI / 2;
      ring.position.set(player.position.x, .12, player.position.z);
      scene.add(ring);
      particles.push({ mesh: ring, life: .7, maxLife: .7, expand: 16, ring: true });

      for (const enemy of [...enemies]) {
        const d = enemy.group.position.distanceTo(player.position);
        if (d < 9.5) {
          damageEnemy(enemy, 62, enemy.group.position.clone().add(new THREE.Vector3(0,1,0)), "lightning");
          const away = enemy.group.position.clone().sub(player.position).setY(0).normalize();
          enemy.group.position.addScaledVector(away, Math.max(0, 4.5 - d * .2));
        }
      }
      showMessage("ARCANE SHOCKWAVE", 1);
    }

    function explodeProjectile(p, pos) {
      const s = p.spell;
      if (s.splash) {
        for (const enemy of [...enemies]) {
          const d = enemy.group.position.distanceTo(pos);
          if (d < s.splash) {
            const falloff = 1 - d / s.splash * .55;
            damageEnemy(enemy, s.damage * falloff, enemy.group.position.clone().add(new THREE.Vector3(0, 1, 0)), s.kind);
          }
        }
      }
      spawnBurst(pos, s.color, s.kind === "sun" ? 40 : 20);
      scene.remove(p.mesh);
      const i = projectiles.indexOf(p);
      if (i >= 0) projectiles.splice(i, 1);
    }

    function spawnBurst(position, color, count = 10) {
      for (let i = 0; i < count; i++) {
        const size = rand(.035, .12);
        const mesh = new THREE.Mesh(
          new THREE.SphereGeometry(size, 5, 5),
          new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 1 })
        );
        mesh.position.copy(position);
        scene.add(mesh);
        particles.push({
          mesh,
          velocity: new THREE.Vector3(rand(-3.5, 3.5), rand(-1, 4.5), rand(-3.5, 3.5)),
          life: rand(.25, .8),
          maxLife: .8
        });
      }
    }

    // ---------- Combat and movement ----------
    function damagePlayer(amount) {
      if (gameEnded) return;
      player.health -= amount;
      const flash = document.getElementById("damageFlash");
      flash.style.opacity = ".68";
      setTimeout(() => flash.style.opacity = "0", 90);
      updateHUD();
      if (player.health <= 0) endGame();
    }

    function resolvePlayerCollisions(next) {
      const horizontal = new THREE.Vector2(next.x, next.z);
      const len = horizontal.length();
      if (len > arenaRadius - 1.2) {
        horizontal.setLength(arenaRadius - 1.2);
        next.x = horizontal.x;
        next.z = horizontal.y;
      }

      for (const o of obstacles) {
        const dx = next.x - o.x;
        const dz = next.z - o.z;
        const dist = Math.hypot(dx, dz);
        const min = o.radius + player.radius;
        if (dist < min) {
          const nx = dist > .001 ? dx / dist : 1;
          const nz = dist > .001 ? dz / dist : 0;
          next.x = o.x + nx * min;
          next.z = o.z + nz * min;
        }
      }
    }

    function updatePlayer(dt) {
      camera.rotation.set(player.pitch, player.yaw, 0);

      const forward = new THREE.Vector3(-Math.sin(player.yaw), 0, -Math.cos(player.yaw));
      const right = new THREE.Vector3(Math.cos(player.yaw), 0, -Math.sin(player.yaw));
      const input = new THREE.Vector3();

      if (keys.KeyW) input.add(forward);
      if (keys.KeyS) input.sub(forward);
      if (keys.KeyD) input.add(right);
      if (keys.KeyA) input.sub(right);
      if (input.lengthSq() > 0) input.normalize();

      const sprinting = (keys.ShiftLeft || keys.ShiftRight) && player.stamina > 0 && input.lengthSq() > 0;
      const speed = sprinting ? 10.2 : 6.4;

      if (sprinting) player.stamina = Math.max(0, player.stamina - 28 * dt);
      else player.stamina = Math.min(player.maxStamina, player.stamina + 18 * dt);

      player.velocity.x = THREE.MathUtils.damp(player.velocity.x, input.x * speed, 12, dt);
      player.velocity.z = THREE.MathUtils.damp(player.velocity.z, input.z * speed, 12, dt);

      if (keys.Space && player.grounded) {
        player.velocity.y = 7.2;
        player.grounded = false;
      }
      player.velocity.y -= 19 * dt;

      const next = player.position.clone().addScaledVector(player.velocity, dt);
      if (next.y <= 1.7) {
        next.y = 1.7;
        player.velocity.y = 0;
        player.grounded = true;
      }

      resolvePlayerCollisions(next);
      player.position.copy(next);
      camera.position.copy(player.position);

      player.mana = Math.min(player.maxMana, player.mana + 8.5 * dt);
      player.shockwaveCooldown = Math.max(0, player.shockwaveCooldown - dt);
    }

    function updateEnemies(dt, time) {
      for (const enemy of [...enemies]) {
        if (enemy.dead) continue;
        enemy.attackCooldown -= dt;
        enemy.rangedCooldown -= dt;
        enemy.hitFlash -= dt;
        enemy.slow = Math.max(0, enemy.slow - dt);

        const bodyMat = enemy.group.children[0]?.material;
        if (bodyMat?.emissive) {
          bodyMat.emissive.setHex(enemy.hitFlash > 0 ? 0x551111 : 0x000000);
        }

        const toPlayer = player.position.clone().sub(enemy.group.position);
        toPlayer.y = 0;
        const dist = toPlayer.length();
        const dir = dist > .001 ? toPlayer.normalize() : new THREE.Vector3();

        const slowFactor = enemy.slow > 0 ? .42 : 1;
        const desired = enemy.type === "wraith" ? 7 : (enemy.type === "boss" ? 4.2 : 1.55);

        if (dist > desired) {
          enemy.group.position.addScaledVector(dir, enemy.speed * slowFactor * dt);
        } else if (enemy.type === "wraith" && dist < 5) {
          enemy.group.position.addScaledVector(dir, -enemy.speed * .4 * dt);
        }

        enemy.group.lookAt(player.position.x, enemy.group.position.y + enemy.scale, player.position.z);
        enemy.group.position.y = Math.sin(time * 2.1 + enemy.bob) * (enemy.type === "wraith" ? .22 : .035);

        // Prevent enemies stacking directly.
        for (const other of enemies) {
          if (other === enemy || other.dead) continue;
          const d = enemy.group.position.distanceTo(other.group.position);
          const min = enemy.radius + other.radius;
          if (d < min && d > .001) {
            enemy.group.position.addScaledVector(
              enemy.group.position.clone().sub(other.group.position).normalize(),
              (min - d) * .018
            );
          }
        }

        if (dist < 2.1 * enemy.scale && enemy.attackCooldown <= 0) {
          damagePlayer(enemy.damage);
          enemy.attackCooldown = enemy.type === "boss" ? 1.05 : rand(.8, 1.35);
          const push = player.position.clone().sub(enemy.group.position).setY(0).normalize();
          player.position.addScaledVector(push, .85);
        }

        if ((enemy.type === "wraith" || enemy.type === "boss") && dist < 25 && enemy.rangedCooldown <= 0) {
          const orb = new THREE.Mesh(
            new THREE.SphereGeometry(enemy.type === "boss" ? .34 : .22, 9, 8),
            new THREE.MeshBasicMaterial({ color: enemy.type === "boss" ? 0xff441f : 0x54a8ff })
          );
          const origin = enemy.group.position.clone().add(new THREE.Vector3(0, 1.7 * enemy.scale, 0));
          orb.position.copy(origin);
          scene.add(orb);
          const aim = player.position.clone().sub(origin).normalize();
          enemyProjectiles.push({
            mesh: orb,
            velocity: aim.multiplyScalar(enemy.type === "boss" ? 12 : 9),
            damage: enemy.type === "boss" ? 18 : 10,
            life: 4
          });
          enemy.rangedCooldown = enemy.type === "boss" ? rand(1.4, 2.1) : rand(2.4, 3.6);
        }
      }
    }

    function updateProjectiles(dt) {
      for (const p of [...projectiles]) {
        p.life -= dt;
        const old = p.mesh.position.clone();
        p.mesh.position.addScaledVector(p.velocity, dt);
        p.mesh.rotation.z += dt * 10;

        // Trail.
        if (Math.random() < .72) spawnBurst(p.mesh.position, p.spell.color, 1);

        let hit = null;
        if (!p.cosmetic) for (const enemy of enemies) {
          if (p.mesh.position.distanceTo(enemy.group.position.clone().add(new THREE.Vector3(0, enemy.scale, 0))) < enemy.radius + p.spell.radius) {
            hit = enemy;
            break;
          }
        }

        if (hit) {
          if (p.spell.splash) explodeProjectile(p, p.mesh.position.clone());
          else {
            damageEnemy(hit, p.spell.damage, p.mesh.position.clone(), p.spell.kind);
            scene.remove(p.mesh);
            projectiles.splice(projectiles.indexOf(p), 1);
          }
        } else if (
          p.life <= 0 ||
          p.mesh.position.y < .1 ||
          Math.hypot(p.mesh.position.x, p.mesh.position.z) > arenaRadius + 3
        ) {
          if (p.spell.splash && p.mesh.position.y < .3) explodeProjectile(p, p.mesh.position.clone());
          else {
            scene.remove(p.mesh);
            projectiles.splice(projectiles.indexOf(p), 1);
          }
        }
      }

      for (const p of [...enemyProjectiles]) {
        p.life -= dt;
        p.mesh.position.addScaledVector(p.velocity, dt);
        if (p.mesh.position.distanceTo(player.position) < .65) {
          damagePlayer(p.damage);
          spawnBurst(p.mesh.position.clone(), p.mesh.material.color.getHex(), 14);
          scene.remove(p.mesh);
          enemyProjectiles.splice(enemyProjectiles.indexOf(p), 1);
        } else if (p.life <= 0 || p.mesh.position.y < 0) {
          scene.remove(p.mesh);
          enemyProjectiles.splice(enemyProjectiles.indexOf(p), 1);
        }
      }
    }

    function updateParticles(dt) {
      for (const p of [...particles]) {
        p.life -= dt;
        if (p.ring) {
          const progress = 1 - p.life / p.maxLife;
          const s = 1 + progress * p.expand;
          p.mesh.scale.setScalar(s);
          p.mesh.material.opacity = Math.max(0, p.life / p.maxLife);
        } else {
          p.velocity.y -= 6 * dt;
          p.mesh.position.addScaledVector(p.velocity, dt);
          p.mesh.material.opacity = Math.max(0, p.life / p.maxLife);
          p.mesh.scale.multiplyScalar(.97);
        }
        if (p.life <= 0) {
          scene.remove(p.mesh);
          particles.splice(particles.indexOf(p), 1);
        }
      }
    }

    function updatePickups(dt, time) {
      for (const p of [...pickups]) {
        p.life -= dt;
        p.group.rotation.y += dt * 1.8;
        p.group.position.y = .8 + Math.sin(time * 3 + p.seed) * .18;
        if (p.group.position.distanceTo(player.position) < 1.45) collectPickup(p);
        else if (p.life <= 0) {
          scene.remove(p.group);
          pickups.splice(pickups.indexOf(p), 1);
        }
      }
    }

    // ---------- UI ----------
    let messageTimer = 0;
    function showMessage(text, seconds = 1.5) {
      const el = document.getElementById("message");
      el.innerHTML = text.replace("\n", "<br>");
      el.style.opacity = "1";
      messageTimer = seconds;
    }

    function updateHUD() {
      document.getElementById("healthFill").style.width = `${Math.max(0, player.health / player.maxHealth * 100)}%`;
      document.getElementById("manaFill").style.width = `${player.mana / player.maxMana * 100}%`;
      document.getElementById("staminaFill").style.width = `${player.stamina / player.maxStamina * 100}%`;
      document.getElementById("healthText").textContent = `${Math.ceil(Math.max(0, player.health))} / ${player.maxHealth}`;
      document.getElementById("manaText").textContent = `${Math.floor(player.mana)} / ${player.maxMana}`;
      document.getElementById("staminaText").textContent = `${Math.floor(player.stamina)} / ${player.maxStamina}`;
      document.getElementById("waveText").textContent = `Wave ${wave || 1}`;
      document.getElementById("scoreText").textContent = `Score ${score} · Enemies ${enemies.length} · Kills ${kills}`;
      document.querySelectorAll(".spell").forEach((el, i) => el.classList.toggle("cooling", cooldowns[i] > 0));
    }

    function endGame() {
      gameEnded = true;
      running = false;
      document.exitPointerLock?.();
      document.getElementById("finalScore").textContent = `You reached wave ${wave}, slew ${kills} foes, and earned ${score} points.`;
      document.getElementById("gameOver").style.display = "grid";
    }

    function resetGame() {
      for (const e of [...enemies]) scene.remove(e.group);
      for (const p of [...projectiles]) scene.remove(p.mesh);
      for (const p of [...enemyProjectiles]) scene.remove(p.mesh);
      for (const p of [...pickups]) scene.remove(p.group);
      enemies.length = projectiles.length = enemyProjectiles.length = pickups.length = 0;

      player.position.set(0, 1.7, 15);
      player.velocity.set(0,0,0);
      player.health = player.maxHealth;
      player.mana = player.maxMana;
      player.stamina = player.maxStamina;
      player.yaw = 0;
      player.pitch = 0;
      player.shockwaveCooldown = 0;

      wave = 0; score = 0; kills = 0; boss = null; wavePending = false;
      cooldowns.fill(0);
      gameEnded = false;
      running = true;
      document.getElementById("gameOver").style.display = "none";
      document.getElementById("bossBarWrap").style.display = "none";
      renderer.domElement.requestPointerLock();
      if (!net.connected || net.isHost) startWave();
      updateHUD();
    }

    function beginGame() {
      document.getElementById("startScreen").style.display = "none";
      running = true;
      renderer.domElement.requestPointerLock();
      if (!net.connected || net.isHost) startWave();
      else showMessage("JOINED THE FELLOWSHIP", 2);
    }

    document.getElementById("startButton").addEventListener("click", () => {
      net.mode = "offline";
      net.isHost = false;
      setNetworkBadge("OFFLINE");
      beginGame();
    });

    document.getElementById("hostButton").addEventListener("click", () => connectLan(true));
    document.getElementById("joinButton").addEventListener("click", () => connectLan(false));
    document.getElementById("joinIp").addEventListener("keydown", event => {
      if (event.key === "Enter") connectLan(false);
    });

    document.getElementById("restartButton").addEventListener("click", resetGame);

    // ---------- Main loop ----------
    let hudAccumulator = 0;
    function animate() {
      requestAnimationFrame(animate);
      const dt = Math.min(clock.getDelta(), .04);
      const time = clock.elapsedTime;

      for (const t of torches) {
        const flicker = 1 + Math.sin(time * 13 + t.seed) * .12 + Math.sin(time * 23 + t.seed * 2) * .06;
        t.flame.scale.set(.9, 1.4 * flicker, .9);
        t.light.intensity = 3.05 * flicker;
      }

      if (running && !gameEnded) {
        updatePlayer(dt);
        if (!net.connected || net.isHost) updateEnemies(dt, time);
        updateProjectiles(dt);
        updateParticles(dt);
        updatePickups(dt, time);
        updateNetworking(dt);

        for (let i = 0; i < cooldowns.length; i++) cooldowns[i] = Math.max(0, cooldowns[i] - dt);

        if (messageTimer > 0) {
          messageTimer -= dt;
          if (messageTimer <= 0) document.getElementById("message").style.opacity = "0";
        }

        hudAccumulator += dt;
        if (hudAccumulator > .08) {
          hudAccumulator = 0;
          updateHUD();
        }
      } else {
        updateParticles(dt);
      }

      renderer.render(scene, camera);
    }

    addEventListener("resize", () => {
      camera.aspect = innerWidth / innerHeight;
      camera.updateProjectionMatrix();
      renderer.setSize(innerWidth, innerHeight);
    });

    updateHUD();
    animate();
  </script>
</body>
</html>