Showcase

Boat game, built by DeepSeek V4.1 Flash 1017

Hostedvia OpenCodeOpenRouter
Running live in your browserNew tab
Speed
127tok/s
First token included
Written
311.4Ktokens
Time to build
41m 0s
Cost to build
$0.47
DeepSeek V4.1 Flash 1017's builds and scores
Show the code the model wrote
HTML148,272 characters
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1" />
<title>NEREID — Black Tide</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Barlow+Condensed:ital,wght@0,400;0,500;0,600;0,700;1,500;1,600;1,700&family=Inter:wght@300;400;500;600&display=swap');

:root{
  --ink:#e9f4f4;
  --muted:rgba(220,238,240,.55);
  --dim:rgba(210,232,236,.3);
  --cyan:#7fe7ff;
  --cyan-soft:#b8f3ff;
  --amber:#ffb25f;
  --red:#ff4d3f;
  --glass:rgba(7,16,20,.46);
  --line:rgba(193,231,238,.18);
  --font-display:'Barlow Condensed',sans-serif;
  --font-ui:'Inter',sans-serif;
}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{margin:0;height:100%;overflow:hidden;background:#04080b;color:var(--ink);font-family:var(--font-ui);}
body{user-select:none;-webkit-user-select:none;}
#app{position:fixed;inset:0;overflow:hidden;isolation:isolate;}
#scene{position:absolute;inset:0;display:block;width:100%;height:100%;outline:none;}
#app.playing #scene{cursor:none;}
#app.playing #startScreen{display:none;}

/* ── atmosphere ─────────────────────────────────────────── */
.vignette{position:absolute;inset:0;pointer-events:none;z-index:3;
  background:radial-gradient(ellipse at 50% 45%,transparent 34%,rgba(2,7,10,.16) 68%,rgba(1,4,7,.58) 100%);
  mix-blend-mode:multiply;}
.grain{position:absolute;inset:-40%;pointer-events:none;z-index:4;opacity:.055;
  background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 220 220' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.86' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
  animation:grainShift 1.1s steps(3) infinite;}
@keyframes grainShift{
  0%{transform:translate(0,0)} 33%{transform:translate(-1.4%,.8%)} 66%{transform:translate(1%,-1.2%)} 100%{transform:translate(0,0)}
}
#damageFlash{position:absolute;inset:0;z-index:5;pointer-events:none;opacity:0;
  background:radial-gradient(ellipse at 50% 50%,transparent 22%,rgba(150,10,0,.18) 63%,rgba(255,36,18,.68) 100%);
  transition:opacity .28s ease-out;}
#waterFlash{position:absolute;inset:0;z-index:5;pointer-events:none;opacity:0;
  background:linear-gradient(to top,rgba(185,240,255,.38),rgba(112,200,224,.12) 24%,transparent 58%);
  transition:opacity .4s ease-out;}

/* ── HUD ────────────────────────────────────────────────── */
#hud{position:absolute;inset:0;z-index:10;pointer-events:none;opacity:0;transition:opacity .7s ease .2s;}
#app.playing #hud{opacity:1;}
.hud-top{position:absolute;top:0;left:0;right:0;display:flex;justify-content:space-between;align-items:flex-start;
  padding:clamp(18px,2.4vw,34px) clamp(18px,3vw,48px);gap:20px;}
.panel{position:relative;background:linear-gradient(135deg,rgba(8,18,23,.62),rgba(5,12,16,.24));
  border:1px solid var(--line);backdrop-filter:blur(10px) saturate(1.1);-webkit-backdrop-filter:blur(10px) saturate(1.1);
  box-shadow:0 18px 60px rgba(0,0,0,.18);padding:15px 18px 14px;min-width:216px;}
.panel::before,.panel::after{content:'';position:absolute;width:7px;height:7px;border-color:rgba(135,230,248,.65);border-style:solid;pointer-events:none;}
.panel::before{top:-1px;left:-1px;border-width:1px 0 0 1px;}
.panel::after{bottom:-1px;right:-1px;border-width:0 1px 1px 0;}
.eyebrow{font-family:var(--font-display);font-size:10px;letter-spacing:.24em;text-transform:uppercase;color:var(--muted);
  display:flex;align-items:center;gap:8px;white-space:nowrap;}
.live-dot{width:5px;height:5px;background:var(--cyan);border-radius:50%;box-shadow:0 0 10px var(--cyan);animation:livePulse 2.4s ease-in-out infinite;}
@keyframes livePulse{0%,100%{opacity:.45}50%{opacity:1}}
.hull-line{display:flex;align-items:baseline;justify-content:space-between;margin-top:13px;gap:18px;}
.hull-label{font-family:var(--font-display);font-size:13px;letter-spacing:.15em;color:rgba(235,248,249,.8);}
#hullVal{font-family:var(--font-display);font-size:25px;line-height:.9;font-weight:600;letter-spacing:.02em;}
.meter{position:relative;height:5px;margin-top:7px;background:rgba(255,255,255,.09);overflow:hidden;}
.meter i{position:absolute;inset:0;width:100%;transform-origin:left center;background:linear-gradient(90deg,#2c8ca0,#9cf0ff);transition:transform .3s ease,background .4s ease;
  box-shadow:0 0 12px rgba(120,230,250,.5);}
.meter::after{content:'';position:absolute;inset:0;background:repeating-linear-gradient(90deg,transparent 0 17px,rgba(0,0,0,.4) 17px 18px);}
.data-row{display:flex;align-items:flex-end;gap:12px;margin-top:12px;}
.data-row.right{justify-content:flex-end;gap:14px;}
.big{font-family:var(--font-display);font-size:31px;line-height:.8;font-weight:500;letter-spacing:.01em;}
.big small{font-family:var(--font-ui);font-size:9px;letter-spacing:.16em;color:var(--muted);margin-left:5px;font-weight:500;}
.small{font-family:var(--font-display);font-size:11px;letter-spacing:.14em;color:var(--muted);white-space:nowrap;}
.small b{color:rgba(238,250,251,.92);font-weight:600;font-size:14px;letter-spacing:.06em;}
.thin{width:1px;height:21px;background:var(--line);display:block;}
.hud-center{text-align:center;min-width:150px;padding-top:16px;padding-bottom:12px;}
.wave-num{font-family:var(--font-display);font-size:46px;line-height:.95;font-weight:500;letter-spacing:.03em;margin-top:2px;}
.threat{font-family:var(--font-display);font-size:10px;letter-spacing:.2em;color:var(--amber);margin-top:3px;text-transform:uppercase;}
.hud-right{text-align:right;}
.score{font-family:var(--font-display);font-size:38px;line-height:.9;letter-spacing:.03em;font-weight:500;margin-top:9px;font-variant-numeric:tabular-nums;}
#radarWrap{position:absolute;left:clamp(18px,3vw,48px);bottom:22px;width:148px;height:168px;padding:10px 10px 8px;
  border:1px solid var(--line);background:linear-gradient(160deg,rgba(7,18,22,.62),rgba(4,12,16,.25));backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);}
#radarWrap::before{content:'';position:absolute;left:-1px;top:-1px;width:7px;height:7px;border-left:1px solid var(--cyan);border-top:1px solid var(--cyan);}
.radar-top{display:flex;justify-content:space-between;font-family:var(--font-display);font-size:8px;letter-spacing:.16em;color:var(--muted);margin-bottom:3px;}
#radar{display:block;width:126px;height:126px;}
#controlsHint{position:absolute;bottom:25px;left:50%;transform:translateX(-50%);font-family:var(--font-display);
  font-size:11px;letter-spacing:.18em;color:rgba(226,245,246,.46);white-space:nowrap;transition:opacity 1.2s ease;
  text-shadow:0 2px 12px rgba(0,0,0,.5);}
#controlsHint.fade{opacity:0;}
#audioBtn{position:absolute;right:clamp(18px,3vw,48px);bottom:22px;z-index:30;pointer-events:none;opacity:0;background:rgba(7,16,20,.4);
  border:1px solid var(--line);color:var(--muted);font-family:var(--font-display);font-size:10px;letter-spacing:.16em;
  padding:8px 12px;cursor:pointer;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);transition:.2s;}
#app.playing #audioBtn{opacity:1;pointer-events:auto;}
#audioBtn:hover{color:var(--ink);border-color:rgba(150,235,250,.46);background:rgba(12,30,38,.58);}

/* ── reticle ────────────────────────────────────────────── */
#reticle{position:absolute;left:50%;top:50%;width:42px;height:42px;transform:translate(-50%,-50%);z-index:20;
  pointer-events:none;opacity:0;transition:opacity .35s ease;filter:drop-shadow(0 0 6px rgba(0,8,10,.8));}
#app.playing #reticle{opacity:1;}
.ret-ring{position:absolute;inset:7px;border:1px solid rgba(198,241,250,.6);border-radius:50%;transition:.15s ease;}
.ret-dot{position:absolute;left:50%;top:50%;width:3px;height:3px;margin:-1.5px 0 0 -1.5px;border-radius:50%;background:#e8fdff;box-shadow:0 0 8px #a9f0ff;}
.ret-line{position:absolute;background:rgba(213,247,253,.8);transition:.15s ease;}
.ret-line.n{left:50%;top:-1px;width:1px;height:11px;margin-left:-.5px;}
.ret-line.s{left:50%;bottom:-1px;width:1px;height:11px;margin-left:-.5px;}
.ret-line.w{top:50%;left:-1px;height:1px;width:11px;margin-top:-.5px;}
.ret-line.e{top:50%;right:-1px;height:1px;width:11px;margin-top:-.5px;}
#reticle.target .ret-ring{border-color:rgba(255,89,63,.92);transform:scale(1.18);box-shadow:0 0 14px rgba(255,67,43,.5);}
#reticle.target .ret-line{background:rgba(255,94,66,.95);}
#reticle.target .ret-dot{background:#ff8c70;box-shadow:0 0 8px #ff4f34;}

/* ── warning, banner ────────────────────────────────────── */
#rogueWarning{position:absolute;left:50%;top:25.5%;transform:translateX(-50%);z-index:18;pointer-events:none;opacity:0;
  font-family:var(--font-display);font-size:14px;font-weight:600;letter-spacing:.3em;color:#ffc47d;
  text-align:center;text-shadow:0 0 20px rgba(255,150,59,.7);transition:opacity .4s ease;white-space:nowrap;
  padding:8px 18px 9px;border:1px solid rgba(255,178,95,.24);background:rgba(9,16,18,.34);
  backdrop-filter:blur(7px);-webkit-backdrop-filter:blur(7px);}
#rogueWarning small{display:block;font-family:var(--font-ui);font-size:8px;letter-spacing:.22em;color:rgba(255,207,158,.72);margin-top:6px;font-weight:400;}
#rogueWarning.show{opacity:1;animation:roguePulse 1.2s ease-in-out infinite;}
@keyframes roguePulse{0%,100%{opacity:.5}50%{opacity:1}}
#banner{position:absolute;left:50%;top:33%;transform:translate(-50%,8px);z-index:18;pointer-events:none;opacity:0;text-align:center;
  transition:opacity .35s ease,transform .45s cubic-bezier(.16,1,.3,1);}
#banner.show{opacity:1;transform:translate(-50%,0);}
#banner .b-kicker{font-family:var(--font-ui);font-size:9px;letter-spacing:.3em;color:var(--cyan);margin-bottom:9px;text-transform:uppercase;}
#banner .b-title{font-family:var(--font-display);font-size:clamp(30px,4.2vw,56px);font-weight:500;letter-spacing:.06em;line-height:.9;text-transform:uppercase;
  text-shadow:0 10px 40px rgba(0,0,0,.55);}
#banner .b-sub{font-family:var(--font-ui);font-size:10px;letter-spacing:.2em;color:var(--muted);margin-top:10px;text-transform:uppercase;}
#pauseOverlay{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);z-index:22;text-align:center;pointer-events:none;
  opacity:0;transition:opacity .3s ease;padding:22px 38px;border:1px solid var(--line);background:rgba(4,12,16,.52);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);}
#pauseOverlay.show{opacity:1;}
#pauseOverlay b{display:block;font-family:var(--font-display);font-size:40px;font-weight:500;letter-spacing:.3em;color:#eaf8f8;}
#pauseOverlay span{display:block;font-size:8px;letter-spacing:.27em;color:var(--muted);margin-top:8px;}

/* ── start screen ───────────────────────────────────────── */
.screen{position:absolute;inset:0;z-index:25;display:flex;align-items:center;}
#startScreen{background:
  linear-gradient(90deg,rgba(2,7,10,.94) 0%,rgba(2,8,11,.82) 31%,rgba(2,8,11,.38) 61%,rgba(2,8,11,.03) 100%),
  linear-gradient(0deg,rgba(2,6,9,.8),transparent 38%);}
.start-inner{width:min(1120px,100%);padding:0 clamp(26px,7vw,105px);transform:translateY(-2vh);}
.start-kicker{display:flex;align-items:center;gap:11px;font-family:var(--font-ui);font-size:9px;letter-spacing:.28em;color:rgba(202,234,238,.6);text-transform:uppercase;margin-bottom:clamp(22px,4vh,40px);}
.start-kicker .live-dot{background:var(--amber);box-shadow:0 0 13px var(--amber);}
h1.logo{font-family:var(--font-display);font-weight:600;font-style:italic;font-size:clamp(92px,14.7vw,215px);line-height:.68;letter-spacing:-.025em;margin:0;
  color:#edfaf9;text-shadow:0 24px 60px rgba(0,0,0,.4);}
.logo span{display:inline-block;color:transparent;-webkit-text-stroke:1px rgba(171,235,246,.42);font-size:.46em;letter-spacing:.1em;transform:translateY(-.23em);margin-left:.14em;font-style:normal;}
.start-sub{font-family:var(--font-display);font-size:clamp(20px,2.2vw,31px);letter-spacing:.5em;color:var(--amber);margin-top:clamp(20px,3.7vh,34px);font-weight:500;}
.start-desc{max-width:535px;font-size:13px;line-height:1.8;font-weight:300;color:rgba(219,238,240,.62);margin:25px 0 0;letter-spacing:.015em;}
.start-desc b{color:rgba(243,253,253,.92);font-weight:500;}
.primary{position:relative;display:inline-flex;align-items:center;gap:22px;margin-top:clamp(26px,4.7vh,42px);padding:16px 25px 16px 31px;
  background:linear-gradient(100deg,rgba(127,231,255,.95),rgba(175,245,255,.82));color:#032329;border:0;cursor:pointer;
  font-family:var(--font-display);font-size:15px;font-weight:700;letter-spacing:.24em;text-transform:uppercase;
  clip-path:polygon(0 0,100% 0,100% calc(100% - 12px),calc(100% - 12px) 100%,0 100%);transition:.24s ease;
  box-shadow:0 16px 46px rgba(60,202,227,.16);}
.primary span{font-family:var(--font-ui);font-size:18px;font-weight:300;letter-spacing:0;}
.primary:hover{background:#e6fcff;transform:translateY(-2px);box-shadow:0 20px 50px rgba(60,202,227,.26);}
.primary:active{transform:translateY(0);}
.controls{display:flex;gap:clamp(22px,3.8vw,52px);margin-top:clamp(34px,6vh,58px);flex-wrap:wrap;}
.controls>div{display:flex;flex-direction:column;gap:8px;}
.keys{display:flex;gap:4px;}
kbd{font-family:var(--font-ui);font-size:10px;font-weight:500;min-width:27px;height:27px;padding:0 7px;display:inline-flex;align-items:center;justify-content:center;
  border:1px solid rgba(193,235,241,.23);background:rgba(186,231,238,.06);color:rgba(232,250,251,.82);border-radius:2px;box-shadow:inset 0 -2px rgba(0,0,0,.24);}
.controls label{font-family:var(--font-display);font-size:9px;letter-spacing:.24em;color:rgba(210,236,240,.43);text-transform:uppercase;}
.start-foot{font-family:var(--font-ui);font-size:8px;letter-spacing:.2em;color:rgba(185,222,228,.33);margin-top:clamp(30px,5vh,48px);text-transform:uppercase;line-height:2;}
.start-foot .slash{color:rgba(255,178,95,.55);padding:0 8px;}
.start-credit{position:absolute;right:clamp(20px,3vw,48px);bottom:26px;font-family:var(--font-display);font-size:9px;letter-spacing:.2em;color:rgba(192,226,232,.28);text-align:right;}

/* ── game over ──────────────────────────────────────────── */
#gameOver{opacity:0;pointer-events:none;justify-content:center;background:radial-gradient(ellipse at center,rgba(4,12,15,.35),rgba(2,6,9,.9));transition:opacity .9s ease;}
#gameOver.show{opacity:1;pointer-events:auto;}
.go-inner{text-align:center;max-width:600px;padding:30px;}
.go-kicker{font-family:var(--font-ui);font-size:9px;letter-spacing:.32em;color:var(--red);margin-bottom:18px;}
.go-title{font-family:var(--font-display);font-size:clamp(60px,9vw,128px);font-style:italic;font-weight:600;line-height:.78;letter-spacing:-.01em;margin:0;
  text-shadow:0 22px 70px rgba(0,0,0,.5);}
.go-title span{-webkit-text-stroke:1px rgba(255,105,89,.5);color:transparent;}
.go-stats{display:flex;justify-content:center;gap:38px;margin-top:35px;}
.go-stat{display:flex;flex-direction:column;gap:5px;}
.go-stat b{font-family:var(--font-display);font-size:30px;font-weight:500;color:#eaf8f8;}
.go-stat label{font-size:8px;letter-spacing:.22em;color:var(--muted);}
.go-inner .primary{margin-top:38px;background:linear-gradient(100deg,rgba(255,109,77,.95),rgba(255,176,118,.85));color:#280b04;box-shadow:0 16px 46px rgba(255,90,55,.14);}
.go-inner .primary:hover{background:#ffd0bb;}

/* ── loading ────────────────────────────────────────────── */
#loading{position:absolute;inset:0;z-index:60;background:#04080b;display:flex;flex-direction:column;align-items:center;justify-content:center;transition:opacity .8s ease;}
#loading.done{opacity:0;pointer-events:none;}
.load-mark{font-family:var(--font-display);font-style:italic;font-size:44px;font-weight:600;letter-spacing:.02em;color:#eaf8f8;}
.load-mark span{color:var(--amber);}
.load-bar{width:170px;height:1px;background:rgba(255,255,255,.1);margin-top:28px;overflow:hidden;}
.load-bar i{display:block;height:100%;width:40%;background:var(--cyan);box-shadow:0 0 15px var(--cyan);animation:loadSlide 1.4s ease-in-out infinite;}
@keyframes loadSlide{0%{transform:translateX(-110%)}100%{transform:translateX(330%)}}
.load-text{font-family:var(--font-ui);font-size:8px;letter-spacing:.28em;color:rgba(205,235,240,.35);margin-top:17px;text-transform:uppercase;}

@media (max-width:760px){
  .hud-top{padding:14px;}
  .panel{min-width:0;padding:10px 12px;}
  .hud-center{display:none;}
  .start-inner{padding:0 25px;transform:none;}
  .start-desc{display:none;}
  .controls{margin-top:26px;gap:20px;}
  .start-foot{display:none;}
  .hud-right{display:none;}
}
</style>
</head>
<body>
<div id="app">
  <canvas id="scene" tabindex="0"></canvas>
  <div class="vignette"></div>
  <div class="grain"></div>
  <div id="damageFlash"></div>
  <div id="waterFlash"></div>

  <div id="hud">
    <div class="hud-top">
      <div class="panel hud-left">
        <div class="eyebrow"><span class="live-dot"></span> Patrol Craft / Nereid</div>
        <div class="hull-line"><span class="hull-label">HULL INTEGRITY</span><span id="hullVal">100</span></div>
        <div class="meter"><i id="hullFill"></i></div>
        <div class="data-row">
          <span class="big"><b id="speedVal">0.0</b><small>KTS</small></span>
          <span class="thin"></span>
          <span class="small">HDG <b id="headingVal">000</b>°</span>
        </div>
      </div>
      <div class="panel hud-center">
        <div class="eyebrow" style="justify-content:center">Hostile Wave</div>
        <div class="wave-num" id="waveVal">01</div>
        <div class="threat" id="threatVal">STANDING BY</div>
      </div>
      <div class="panel hud-right">
        <div class="eyebrow" style="justify-content:flex-end">Contact / Score</div>
        <div class="score" id="scoreVal">0</div>
        <div class="data-row right">
          <span class="small">HOSTILES <b id="enemyVal">0</b></span>
          <span class="thin"></span>
          <span class="small">KILLS <b id="killVal">0</b></span>
        </div>
      </div>
    </div>
    <div id="radarWrap">
      <div class="radar-top"><span>SURFACE CONTACTS</span><span>250 M</span></div>
      <canvas id="radar" width="200" height="200"></canvas>
    </div>
    <div id="controlsHint">W / S &nbsp;THROTTLE &nbsp;·&nbsp; A / D &nbsp;RUDDER &nbsp;·&nbsp; MOUSE &nbsp;AIM &nbsp;·&nbsp; CLICK &nbsp;FIRE &nbsp;·&nbsp; SHIFT &nbsp;BOOST</div>
  </div>

  <div id="reticle">
    <div class="ret-ring"></div>
    <div class="ret-dot"></div>
    <div class="ret-line n"></div><div class="ret-line s"></div>
    <div class="ret-line w"></div><div class="ret-line e"></div>
  </div>

  <div id="rogueWarning">ROGUE SWELL INBOUND<small>REDUCE SPEED · BRACE FOR IMPACT</small></div>

  <div id="pauseOverlay"><b>PAUSED</b><span>PRESS P TO RESUME</span></div>

  <div id="banner">
    <div class="b-kicker" id="bannerKicker">TRANSMISSION</div>
    <div class="b-title" id="bannerTitle">WAVE 01</div>
    <div class="b-sub" id="bannerSub">MARAUDERS INBOUND</div>
  </div>

  <button id="audioBtn" aria-label="Toggle sound">SOUND ON</button>

  <div id="startScreen" class="screen">
    <div class="start-inner">
      <div class="start-kicker"><span class="live-dot"></span> Open water combat protocol</div>
      <h1 class="logo">NEREID<span>07</span></h1>
      <div class="start-sub">BLACK TIDE</div>
      <p class="start-desc">Command the patrol boat <b>NEREID</b> through a hostile seaway. Marauder skiffs hunt in packs, using the swell for cover. Read the water. Keep your hull intact. Put them under.</p>
      <button id="startBtn" class="primary">SET SAIL <span>↗</span></button>
      <div class="controls">
        <div><span class="keys"><kbd>W</kbd><kbd>S</kbd></span><label>Throttle</label></div>
        <div><span class="keys"><kbd>A</kbd><kbd>D</kbd></span><label>Rudder</label></div>
        <div><span class="keys"><kbd>MOUSE</kbd></span><label>Aim / Fire</label></div>
        <div><span class="keys"><kbd>SHIFT</kbd></span><label>Emergency power</label></div>
      </div>
      <div class="start-foot">HEADPHONES RECOMMENDED<span class="slash">/</span>SCROLL TO ZOOM<span class="slash">/</span>M TO MUTE</div>
    </div>
    <div class="start-credit">NORTH SEA / SECTOR 07<br>SEA STATE 4 · VISIBILITY 8 NM</div>
  </div>

  <div id="gameOver" class="screen">
    <div class="go-inner">
      <div class="go-kicker">SIGNAL LOST / HULL BREACH</div>
      <h2 class="go-title">NEREID<span> DOWN</span></h2>
      <div class="go-stats">
        <div class="go-stat"><b id="goScore">0</b><label>SCORE</label></div>
        <div class="go-stat"><b id="goKills">0</b><label>KILLS</label></div>
        <div class="go-stat"><b id="goWave">1</b><label>WAVE</label></div>
      </div>
      <button id="restartBtn" class="primary">RELAUNCH <span>↗</span></button>
    </div>
  </div>

  <div id="loading">
    <div class="load-mark">NEREID<span>.</span></div>
    <div class="load-bar"><i></i></div>
    <div class="load-text">Charting the black tide</div>
  </div>
</div>

<script type="importmap">
{
  "imports": {
    "three": "https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js",
    "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/"
  }
}
</script>

<script type="module">
import * as THREE from 'three';
import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js';

/* =========================================================================
   NEREID — procedural ocean, buoyancy physics, combat.
   Everything below is intentionally self-contained.
   ========================================================================= */

const TAU = Math.PI * 2;
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const lerp = (a, b, t) => a + (b - a) * t;
const smoothstep = (a, b, x) => { const t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); };
const rand = (a = 0, b = 1) => a + Math.random() * (b - a);
const angleDelta = (a, b) => { let d = (b - a + Math.PI) % TAU; if (d < 0) d += TAU; return d - Math.PI; };
const damp = (a, b, lambda, dt) => lerp(a, b, 1 - Math.exp(-lambda * dt));
const smoothAngle = (a, b, lambda, dt) => a + angleDelta(a, b) * (1 - Math.exp(-lambda * dt));

function hash2(x, y) {
  let h = Math.sin(x * 127.1 + y * 311.7) * 43758.5453123;
  return h - Math.floor(h);
}
function noise2(x, y) {
  const xi = Math.floor(x), yi = Math.floor(y);
  const xf = x - xi, yf = y - yi;
  const u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf);
  const a = hash2(xi, yi), b = hash2(xi + 1, yi), c = hash2(xi, yi + 1), d = hash2(xi + 1, yi + 1);
  return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
function fbm(x, y, octaves = 4) {
  let value = 0, amp = 0.5, norm = 0;
  for (let i = 0; i < octaves; i++) {
    value += amp * noise2(x, y);
    norm += amp;
    x = x * 2.03 + 17.17;
    y = y * 2.03 - 9.31;
    amp *= 0.5;
  }
  return value / norm;
}
function pnoise(x, y, period) {
  const xi = Math.floor(x), yi = Math.floor(y);
  const xf = x - xi, yf = y - yi;
  const u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf);
  const wrap = (n, p) => ((n % p) + p) % p;
  const a = hash2(wrap(xi, period), wrap(yi, period));
  const b = hash2(wrap(xi + 1, period), wrap(yi, period));
  const c = hash2(wrap(xi, period), wrap(yi + 1, period));
  const d = hash2(wrap(xi + 1, period), wrap(yi + 1, period));
  return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
function pfbm(x, y, octaves = 5, basePeriod = 4) {
  let value = 0, amp = 0.5, norm = 0, f = 1;
  for (let i = 0; i < octaves; i++) {
    value += amp * pnoise(x * f, y * f, basePeriod * f);
    norm += amp;
    amp *= 0.5;
    f *= 2;
  }
  return value / norm;
}

/* ── procedural textures ──────────────────────────────────────────────── */
function generateFoamTexture() {
  const W = 512, H = 512;
  const canvas = document.createElement('canvas');
  canvas.width = W; canvas.height = H;
  const ctx = canvas.getContext('2d');
  const img = ctx.createImageData(W, H);
  for (let y = 0; y < H; y++) {
    for (let x = 0; x < W; x++) {
      const u = x / W, v = y / H;
      const n = pfbm(u * 4, v * 4, 5, 4);
      const ridge = 1 - Math.abs(n * 2 - 1);
      const fine = pfbm(u * 13 + 5.1, v * 13 - 3.7, 3, 13);
      let a = smoothstep(0.57, 0.76, n) * 0.85;
      a += smoothstep(0.76, 0.92, ridge) * 0.22;
      a += smoothstep(0.69, 0.84, fine) * 0.13;
      a = clamp(a, 0, 1);
      const idx = (y * W + x) * 4;
      img.data[idx] = 255;
      img.data[idx + 1] = 255;
      img.data[idx + 2] = 255;
      img.data[idx + 3] = Math.round(a * 255);
    }
  }
  ctx.putImageData(img, 0, 0);
  const tex = new THREE.CanvasTexture(canvas);
  tex.wrapS = THREE.RepeatWrapping;
  tex.wrapT = THREE.RepeatWrapping;
  tex.flipY = false;
  tex.colorSpace = THREE.NoColorSpace;
  tex.anisotropy = 8;
  return tex;
}

function generateCloudTexture() {
  const W = 1024, H = 512;
  const canvas = document.createElement('canvas');
  canvas.width = W; canvas.height = H;
  const ctx = canvas.getContext('2d');
  const img = ctx.createImageData(W, H);
  for (let y = 0; y < H; y++) {
    const v = (y + 0.5) / H;
    const lat = (v - 0.5) * Math.PI;
    const cl = Math.cos(lat), dy = Math.sin(lat);
    for (let x = 0; x < W; x++) {
      const lon = ((x + 0.5) / W) * TAU;
      const dx = cl * Math.cos(lon), dz = cl * Math.sin(lon);
      let a = 0;
      if (dy > 0.012) {
        const inv = 1 / (dy + 0.18);
        const px = dx * inv * 2.45, pz = dz * inv * 2.45;
        const warpX = fbm(px * 0.42 + 12.3, pz * 0.42 - 8.1, 3);
        const warpZ = fbm(px * 0.42 - 5.7, pz * 0.42 + 3.9, 3);
        const broad = fbm(px * 1.55 + warpX * 1.6 + 6.4, pz * 1.55 + warpZ * 1.6 - 2.7, 5);
        const detail = fbm(px * 5.0 + warpZ * 0.8 - 9.2, pz * 5.0 + warpX * 0.8 + 3.4, 4);
        const val = broad * 0.68 + detail * 0.32;
        a = smoothstep(0.49, 0.645, val);
        a *= smoothstep(0.018, 0.145, dy);
        a *= 0.68 + 0.32 * smoothstep(0.42, 0.7, detail);
      }
      const idx = (y * W + x) * 4;
      img.data[idx] = 255;
      img.data[idx + 1] = 249;
      img.data[idx + 2] = 243;
      img.data[idx + 3] = Math.round(clamp(a, 0, 1) * 255);
    }
  }
  ctx.putImageData(img, 0, 0);
  const tex = new THREE.CanvasTexture(canvas);
  tex.wrapS = THREE.RepeatWrapping;
  tex.wrapT = THREE.ClampToEdgeWrapping;
  tex.flipY = false;
  tex.colorSpace = THREE.NoColorSpace;
  tex.anisotropy = 4;
  return tex;
}

function generateWaterNormalTexture() {
  const W = 512, H = 512;
  const heights = new Float32Array(W * H);
  for (let y = 0; y < H; y++) {
    for (let x = 0; x < W; x++) {
      const u = (x / W) * 8, v = (y / H) * 8;
      let h = pfbm(u, v, 5, 8);
      h += pfbm(u * 2.7 + 4.1, v * 2.7 - 2.3, 3, 22) * 0.24;
      heights[y * W + x] = h;
    }
  }
  const canvas = document.createElement('canvas');
  canvas.width = W; canvas.height = H;
  const ctx = canvas.getContext('2d');
  const img = ctx.createImageData(W, H);
  const at = (x, y) => heights[((y + H) % H) * W + ((x + W) % W)];
  const strength = 9.5;
  for (let y = 0; y < H; y++) {
    for (let x = 0; x < W; x++) {
      const dx = (at(x + 1, y) - at(x - 1, y)) * strength;
      const dy = (at(x, y + 1) - at(x, y - 1)) * strength;
      const len = Math.hypot(dx, dy, 1);
      const nx = -dx / len, ny = -dy / len, nz = 1 / len;
      const i = (y * W + x) * 4;
      img.data[i] = Math.round((nx * 0.5 + 0.5) * 255);
      img.data[i + 1] = Math.round((ny * 0.5 + 0.5) * 255);
      img.data[i + 2] = Math.round((nz * 0.5 + 0.5) * 255);
      img.data[i + 3] = 255;
    }
  }
  ctx.putImageData(img, 0, 0);
  const tex = new THREE.CanvasTexture(canvas);
  tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
  tex.flipY = false;
  tex.colorSpace = THREE.NoColorSpace;
  tex.anisotropy = 8;
  return tex;
}

function generateTeakTexture() {
  const W = 512, H = 512;
  const canvas = document.createElement('canvas');
  canvas.width = W; canvas.height = H;
  const ctx = canvas.getContext('2d');
  ctx.fillStyle = '#5a3d28';
  ctx.fillRect(0, 0, W, H);
  const planks = 9;
  for (let i = 0; i < planks; i++) {
    const x = (i / planks) * W;
    const w = W / planks;
    const tone = 74 + Math.floor(hash2(i, 3) * 22);
    ctx.fillStyle = `rgb(${tone},${Math.floor(tone * 0.68)},${Math.floor(tone * 0.44)})`;
    ctx.fillRect(x + 1, 0, w - 2, H);
    ctx.strokeStyle = 'rgba(24,14,8,.5)';
    ctx.lineWidth = 1.5;
    ctx.beginPath();
    ctx.moveTo(x + 0.75, 0);
    ctx.lineTo(x + 0.75, H);
    ctx.stroke();
    for (let g = 0; g < 18; g++) {
      const gx = x + 4 + hash2(i, g) * (w - 8);
      ctx.strokeStyle = `rgba(${20 + hash2(g, i) * 28},${12 + hash2(g, i + 2) * 18},${6 + hash2(g, i + 4) * 10},${0.1 + hash2(g, i + 7) * 0.15})`;
      ctx.lineWidth = 0.6 + hash2(g, i + 9) * 1.2;
      ctx.beginPath();
      ctx.moveTo(gx, 0);
      ctx.bezierCurveTo(gx + 8, H * 0.25, gx - 5, H * 0.68, gx + 3, H);
      ctx.stroke();
    }
  }
  const tex = new THREE.CanvasTexture(canvas);
  tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
  tex.colorSpace = THREE.SRGBColorSpace;
  tex.anisotropy = 8;
  return tex;
}

function makeSoftSpriteTexture(size = 128, warm = false) {
  const canvas = document.createElement('canvas');
  canvas.width = canvas.height = size;
  const ctx = canvas.getContext('2d');
  const g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
  if (warm) {
    g.addColorStop(0, 'rgba(255,255,242,1)');
    g.addColorStop(0.08, 'rgba(255,244,206,1)');
    g.addColorStop(0.24, 'rgba(255,185,104,.8)');
    g.addColorStop(0.52, 'rgba(255,121,45,.2)');
    g.addColorStop(1, 'rgba(255,80,20,0)');
  } else {
    g.addColorStop(0, 'rgba(255,255,255,1)');
    g.addColorStop(0.22, 'rgba(255,255,255,.85)');
    g.addColorStop(0.55, 'rgba(225,245,248,.3)');
    g.addColorStop(1, 'rgba(210,240,245,0)');
  }
  ctx.fillStyle = g;
  ctx.fillRect(0, 0, size, size);
  const tex = new THREE.CanvasTexture(canvas);
  tex.colorSpace = THREE.SRGBColorSpace;
  return tex;
}

function makeSkullDecal() {
  const S = 256;
  const canvas = document.createElement('canvas');
  canvas.width = canvas.height = S;
  const ctx = canvas.getContext('2d');
  ctx.clearRect(0, 0, S, S);
  ctx.translate(S / 2, S / 2);
  ctx.strokeStyle = 'rgba(235,223,198,.82)';
  ctx.fillStyle = 'rgba(235,223,198,.82)';
  ctx.lineWidth = 8;
  ctx.lineCap = 'round';
  ctx.beginPath();
  ctx.arc(0, -24, 62, Math.PI * 0.05, Math.PI * 0.95, false);
  ctx.arc(0, -24, 62, Math.PI * 1.05, Math.PI * 1.95, false);
  ctx.stroke();
  ctx.beginPath();
  ctx.ellipse(-26, -31, 16, 20, -0.2, 0, TAU);
  ctx.ellipse(26, -31, 16, 20, 0.2, 0, TAU);
  ctx.fillStyle = 'rgba(10,14,14,.96)';
  ctx.fill();
  ctx.beginPath();
  ctx.moveTo(0, -8);
  ctx.lineTo(9, 14);
  ctx.lineTo(-9, 14);
  ctx.closePath();
  ctx.fill();
  ctx.fillStyle = 'rgba(235,223,198,.82)';
  for (let i = -3; i <= 3; i++) {
    ctx.fillRect(i * 13 - 4, 18, 8, 28);
  }
  ctx.strokeStyle = 'rgba(235,223,198,.82)';
  ctx.lineWidth = 7;
  ctx.beginPath();
  ctx.moveTo(-54, 53); ctx.lineTo(-90, 93);
  ctx.moveTo(54, 53); ctx.lineTo(90, 93);
  ctx.stroke();
  const tex = new THREE.CanvasTexture(canvas);
  tex.colorSpace = THREE.SRGBColorSpace;
  tex.anisotropy = 4;
  return tex;
}

function makeNameDecal(text, sub) {
  const W = 512, H = 160;
  const canvas = document.createElement('canvas');
  canvas.width = W; canvas.height = H;
  const ctx = canvas.getContext('2d');
  ctx.clearRect(0, 0, W, H);
  ctx.textAlign = 'center';
  ctx.fillStyle = 'rgba(228,221,198,.92)';
  ctx.font = '600 60px "Barlow Condensed", sans-serif';
  ctx.letterSpacing = '10px';
  ctx.fillText(text, W / 2, 92);
  ctx.fillStyle = 'rgba(255,177,95,.75)';
  ctx.fillRect(W / 2 - 64, 108, 128, 2);
  if (sub) {
    ctx.fillStyle = 'rgba(214,231,231,.46)';
    ctx.font = '500 18px "Inter", sans-serif';
    ctx.letterSpacing = '7px';
    ctx.fillText(sub, W / 2, 136);
  }
  const tex = new THREE.CanvasTexture(canvas);
  tex.colorSpace = THREE.SRGBColorSpace;
  tex.anisotropy = 4;
  return tex;
}

const TEX = {
  foam: null,
  waterNormal: null,
  clouds: null,
  soft: null,
  sun: null,
  teak: null,
  skull: null,
  name: null
};

/* ── renderer / scene ─────────────────────────────────────────────────── */
const canvas = document.getElementById('scene');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance', alpha: false });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.02;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.setClearColor(0x09141b, 1);

const scene = new THREE.Scene();
const FOG_COLOR = new THREE.Color(0x8a999e);
scene.fog = new THREE.FogExp2(FOG_COLOR, 0.00135);

const camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 9000);
camera.position.set(0, 6, -17);

const sunDir = new THREE.Vector3(-0.46, 0.155, 0.873).normalize();
const sunColor = new THREE.Color(0xffd1a0);
const skyColor = new THREE.Color(0x9ab9cd);

const hemi = new THREE.HemisphereLight(0xa7c6dc, 0x13242a, 1.16);
scene.add(hemi);

const sunLight = new THREE.DirectionalLight(0xffc58e, 3.2);
sunLight.position.copy(sunDir).multiplyScalar(160);
sunLight.castShadow = true;
sunLight.shadow.mapSize.set(2048, 2048);
sunLight.shadow.camera.near = 10;
sunLight.shadow.camera.far = 360;
sunLight.shadow.camera.left = -48;
sunLight.shadow.camera.right = 48;
sunLight.shadow.camera.top = 48;
sunLight.shadow.camera.bottom = -48;
sunLight.shadow.bias = -0.0004;
sunLight.shadow.normalBias = 0.035;
scene.add(sunLight);
scene.add(sunLight.target);

const fillLight = new THREE.DirectionalLight(0x7d9fba, 1.34);
fillLight.position.set(0.45, 0.5, -0.75);
scene.add(fillLight);

const cockpitLight = new THREE.PointLight(0xffc07a, 18.0, 12, 2.0);
cockpitLight.position.set(0, 1.25, -0.55);
scene.add(cockpitLight);

/* ── sky ──────────────────────────────────────────────────────────────── */
TEX.clouds = generateCloudTexture();

const skyMaterial = new THREE.ShaderMaterial({
  side: THREE.BackSide,
  depthWrite: false,
  depthTest: false,
  uniforms: {
    uSunDir: { value: sunDir },
    uSunColor: { value: new THREE.Color(0xffb774) },
    uClouds: { value: TEX.clouds },
    uCloudOffset: { value: 0 },
    uTime: { value: 0 },
    uExposure: { value: 1.02 },
    uOutput: { value: 1 }
  },
  vertexShader: /* glsl */`
    varying vec3 vDir;
    void main(){
      vec4 wp = modelMatrix * vec4(position, 1.0);
      vDir = normalize(wp.xyz - cameraPosition);
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
  `,
  fragmentShader: /* glsl */`
    precision highp float;
    uniform vec3 uSunDir;
    uniform vec3 uSunColor;
    uniform sampler2D uClouds;
    uniform float uCloudOffset;
    uniform float uTime;
    uniform float uExposure;
    uniform float uOutput;
    varying vec3 vDir;

    vec3 aces(vec3 x){
      return clamp((x*(2.51*x+0.03))/(x*(2.43*x+0.59)+0.14), 0.0, 1.0);
    }
    vec3 toSRGB(vec3 c){
      return mix(1.055*pow(max(c,vec3(0.0)), vec3(1.0/2.4))-0.055, c*12.92, lessThanEqual(c,vec3(0.0031308)));
    }
    float hash12(vec2 p){
      vec3 p3 = fract(vec3(p.xyx) * .1031);
      p3 += dot(p3, p3.yzx + 33.33);
      return fract((p3.x + p3.y) * p3.z);
    }

    void main(){
      vec3 dir = normalize(vDir);
      float up = max(dir.y, 0.0);
      float h = pow(1.0 - up, 3.2);

      vec3 zenith = vec3(0.012, 0.045, 0.12);
      vec3 mid = vec3(0.09, 0.19, 0.33);
      vec3 horizon = vec3(0.72, 0.38, 0.23);

      vec3 col = mix(zenith, mid, smoothstep(0.0, 0.62, up));
      col = mix(col, horizon, h * 0.96);

      float sd = max(dot(dir, uSunDir), 0.0);
      float horizonBand = pow(1.0 - up, 5.0);
      col += uSunColor * (pow(sd, 5.0) * 0.32 + pow(sd, 28.0) * 0.82 + pow(sd, 140.0) * 1.9);
      col += vec3(0.42, 0.17, 0.05) * horizonBand * (0.35 + pow(sd, 2.0) * 0.9);

      // sun disk
      float disk = smoothstep(0.99945, 0.99972, sd);
      col += vec3(1.9, 1.48, 0.96) * disk * 22.0;
      col += uSunColor * pow(sd, 1200.0) * 4.0;

      // clouds projected on an imaginary high plane
      if (dir.y > 0.012 && uCloudOffset >= 0.0) {
        float lon = atan(dir.z, dir.x) * 0.15915494 + 0.5;
        float lat = asin(clamp(dir.y, -1.0, 1.0)) * 0.31830989 + 0.48;
        float c1 = texture2D(uClouds, vec2(lon * 2.0 + uCloudOffset, lat)).a;
        float c2 = texture2D(uClouds, vec2(lon * 3.0 - uCloudOffset * 1.7 + 0.37, lat * 1.7 - 0.05)).a;
        float cloud = smoothstep(0.15, 0.66, c1 * 0.78 + c2 * 0.39);
        cloud *= smoothstep(0.012, 0.1, dir.y);
        cloud *= 1.0 - smoothstep(0.82, 1.0, up) * 0.2;

        float lit = pow(sd, 2.2);
        vec3 shade = vec3(0.21, 0.21, 0.26);
        vec3 light = vec3(1.48, 0.82, 0.46);
        vec3 cloudCol = mix(shade, light, 0.2 + lit * 0.8);
        cloudCol += uSunColor * pow(sd, 14.0) * 1.45 * smoothstep(0.16, 0.7, c1);
        cloudCol += vec3(0.2, 0.2, 0.25) * pow(1.0 - up, 3.0);
        float cloudStrength = uOutput > 0.5 ? 0.76 : 0.27;
        col = mix(col, cloudCol, cloud * cloudStrength);
      }

      // faint high haze
      col += vec3(0.035, 0.05, 0.06) * pow(1.0 - up, 9.0);

      col *= 1.0;
      if (uOutput > 0.5) {
        col = aces(col * uExposure);
        col = toSRGB(col);
      } else {
        col = max(col, vec3(0.0));
      }

      // subtle dither
      col += (hash12(gl_FragCoord.xy) - 0.5) / 255.0;
      gl_FragColor = vec4(col, 1.0);
    }
  `
});

const skyGeo = new THREE.SphereGeometry(5000, 64, 32);
const skyMesh = new THREE.Mesh(skyGeo, skyMaterial);
skyMesh.frustumCulled = false;
skyMesh.renderOrder = -2000;
scene.add(skyMesh);

TEX.sun = makeSoftSpriteTexture(512, true);
const sunGlow = new THREE.Sprite(new THREE.SpriteMaterial({
  map: TEX.sun, color: 0xffcf9a, transparent: true, opacity: 0.48,
  blending: THREE.AdditiveBlending, depthWrite: false, depthTest: true, fog: false
}));
sunGlow.scale.set(540, 540, 1);
sunGlow.renderOrder = -1000;
scene.add(sunGlow);

const sunCore = new THREE.Sprite(new THREE.SpriteMaterial({
  map: TEX.sun, color: 0xfff0ce, transparent: true, opacity: 0.82,
  blending: THREE.AdditiveBlending, depthWrite: false, depthTest: true, fog: false
}));
sunCore.scale.set(160, 160, 1);
sunCore.renderOrder = -999;
scene.add(sunCore);

/* environment for PBR: bake the procedural sky */
{
  const pmrem = new THREE.PMREMGenerator(renderer);
  const skyScene = new THREE.Scene();
  const envSky = new THREE.Mesh(skyGeo, skyMaterial);
  envSky.frustumCulled = false;
  skyScene.add(envSky);
  skyMaterial.uniforms.uOutput.value = 0;
  const envRT = pmrem.fromScene(skyScene, 0, 0.1, 12000);
  scene.environment = envRT.texture;
  scene.environmentIntensity = 0.9;
  skyMaterial.uniforms.uOutput.value = 1;
  pmrem.dispose();
}

/* ── waves ────────────────────────────────────────────────────────────── */
const WAVES = [
  { dx: 0.951, dz: 0.309, amp: 0.58, len: 62.0, phase: 1.3, harm: 0.12, hphase: 0.4 },
  { dx: -0.276, dz: 0.961, amp: 0.34, len: 37.0, phase: 4.1, harm: 0.11, hphase: 2.0 },
  { dx: 0.623, dz: -0.782, amp: 0.22, len: 22.5, phase: 2.2, harm: 0.10, hphase: 5.0 },
  { dx: -0.882, dz: 0.471, amp: 0.105, len: 13.0, phase: 0.8, harm: 0.08, hphase: 1.1 },
  { dx: 0.147, dz: 0.989, amp: 0.055, len: 7.4, phase: 5.3, harm: 0.06, hphase: 3.3 },
  { dx: -0.655, dz: -0.756, amp: 0.028, len: 4.6, phase: 3.7, harm: 0.05, hphase: 0.9 }
].map(w => {
  const k = TAU / w.len;
  return { ...w, k, omega: Math.sqrt(9.81 * k) };
});
const WAVE_SCALE = 1.28;

const rogue = {
  amp: 0, target: 0, dir: new THREE.Vector2(0.7, 0.7), k: TAU / 72, omega: 0,
  offset: 0, active: false, timer: 0, next: 13, warned: false
};
rogue.omega = Math.sqrt(9.81 * rogue.k);

function waveHeight(x, z, t) {
  let h = 0;
  for (let i = 0; i < WAVES.length; i++) {
    const w = WAVES[i];
    const ph = (x * w.dx + z * w.dz) * w.k - t * w.omega + w.phase;
    h += w.amp * (Math.sin(ph) + w.harm * Math.sin(2 * ph + w.hphase));
  }
  h += rogue.amp * Math.sin((x * rogue.dir.x + z * rogue.dir.y) * rogue.k - t * rogue.omega + rogue.offset);
  return h * WAVE_SCALE;
}
function waveVerticalSpeed(x, z, t) {
  let v = 0;
  for (let i = 0; i < WAVES.length; i++) {
    const w = WAVES[i];
    const ph = (x * w.dx + z * w.dz) * w.k - t * w.omega + w.phase;
    v -= w.omega * w.amp * (Math.cos(ph) + 2 * w.harm * Math.cos(2 * ph + w.hphase));
  }
  v -= rogue.amp * rogue.omega * Math.cos((x * rogue.dir.x + z * rogue.dir.y) * rogue.k - t * rogue.omega + rogue.offset);
  return v * WAVE_SCALE;
}
function waveNormal(x, z, t, out = new THREE.Vector3()) {
  let gx = 0, gz = 0;
  for (let i = 0; i < WAVES.length; i++) {
    const w = WAVES[i];
    const ph = (x * w.dx + z * w.dz) * w.k - t * w.omega + w.phase;
    const d = w.amp * w.k * (Math.cos(ph) + 2 * w.harm * Math.cos(2 * ph + w.hphase));
    gx += w.dx * d;
    gz += w.dz * d;
  }
  const rp = (x * rogue.dir.x + z * rogue.dir.y) * rogue.k - t * rogue.omega + rogue.offset;
  const rd = rogue.amp * rogue.k * Math.cos(rp);
  gx += rogue.dir.x * rd;
  gz += rogue.dir.y * rd;
  return out.set(-gx * WAVE_SCALE, 1, -gz * WAVE_SCALE).normalize();
}

/* Shader wave code generated from the same constants. */
const waveShaderCode = WAVES.map(w => `
  ph = dot(xz, vec2(${w.dx.toFixed(6)}, ${w.dz.toFixed(6)})) * ${w.k.toFixed(7)} - t * ${w.omega.toFixed(7)} + ${w.phase.toFixed(6)};
  h += ${w.amp.toFixed(6)} * (sin(ph) + ${w.harm.toFixed(6)} * sin(2.0 * ph + ${w.hphase.toFixed(6)}));
  grad += vec2(${w.dx.toFixed(6)}, ${w.dz.toFixed(6)}) * ${(w.amp * w.k).toFixed(8)} * (cos(ph) + ${(2 * w.harm).toFixed(6)} * cos(2.0 * ph + ${w.hphase.toFixed(6)}));
`).join('\n');

/* ── ocean ────────────────────────────────────────────────────────────── */
TEX.foam = generateFoamTexture();
TEX.waterNormal = generateWaterNormalTexture();

function createOceanGeometry(half = 1450, segments = 400, power = 1.32) {
  const positions = [], uvs = [], indices = [];
  for (let j = 0; j <= segments; j++) {
    const v = (j / segments) * 2 - 1;
    const z = Math.sign(v) * Math.pow(Math.abs(v), power) * half;
    for (let i = 0; i <= segments; i++) {
      const u = (i / segments) * 2 - 1;
      const x = Math.sign(u) * Math.pow(Math.abs(u), power) * half;
      positions.push(x, 0, z);
      uvs.push(i / segments, j / segments);
    }
  }
  for (let j = 0; j < segments; j++) {
    for (let i = 0; i < segments; i++) {
      const a = j * (segments + 1) + i;
      const b = a + 1;
      const c = a + segments + 1;
      const d = c + 1;
      indices.push(a, c, b, b, c, d);
    }
  }
  const geo = new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
  geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
  geo.setIndex(indices);
  geo.boundingSphere = new THREE.Sphere(new THREE.Vector3(), half * 1.5);
  return geo;
}

const reflectionRT = new THREE.WebGLRenderTarget(2, 2, {
  type: THREE.HalfFloatType,
  minFilter: THREE.LinearFilter,
  magFilter: THREE.LinearFilter,
  depthBuffer: true,
  stencilBuffer: false
});
reflectionRT.texture.generateMipmaps = false;
const reflectionCamera = new THREE.PerspectiveCamera();
const reflectionClip = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0.02);

const waterUniforms = {
  uTime: { value: 0 },
  uWaveScale: { value: WAVE_SCALE },
  uSunDir: { value: sunDir },
  uSunColor: { value: new THREE.Color(0xffc58e) },
  uReflection: { value: reflectionRT.texture },
  uTextureMatrix: { value: new THREE.Matrix4() },
  uFoamTex: { value: TEX.foam },
  uWaterNormal: { value: TEX.waterNormal },
  uFoamStrength: { value: 1.65 },
  uReflectionStrength: { value: 1.0 },
  uCamPos: { value: new THREE.Vector3() },
  uBoatPos: { value: new THREE.Vector3() },
  uBoatSpeed: { value: 0 },
  uBoatShadow: { value: 1 },
  uResolution: { value: new THREE.Vector2(1, 1) },
  uFogColor: { value: new THREE.Color(0x8a999e) },
  uFogDensity: { value: 0.00135 },
  uExposure: { value: 1.02 },
  uOutput: { value: 1 },
  uRogueDir: { value: rogue.dir },
  uRogueAmp: { value: 0 },
  uRogueK: { value: rogue.k },
  uRogueOmega: { value: rogue.omega },
  uRogueOffset: { value: 0 }
};

const waterMaterial = new THREE.ShaderMaterial({
  uniforms: waterUniforms,
  vertexShader: /* glsl */`
    precision highp float;
    uniform float uTime;
    uniform float uWaveScale;
    uniform vec2 uRogueDir;
    uniform float uRogueAmp;
    uniform float uRogueK;
    uniform float uRogueOmega;
    uniform float uRogueOffset;
    uniform mat4 uTextureMatrix;
    varying vec3 vWorldPos;
    varying vec3 vNormalW;
    varying vec4 vReflectCoord;
    varying float vHeight;

    void main(){
      vec3 p = position;
      vec3 wp = (modelMatrix * vec4(p, 1.0)).xyz;
      vec2 xz = wp.xz;
      float t = uTime;
      float h = 0.0;
      vec2 grad = vec2(0.0);
      float ph;
      ${waveShaderCode}
      float rp = dot(xz, uRogueDir) * uRogueK - t * uRogueOmega + uRogueOffset;
      h += uRogueAmp * sin(rp);
      grad += uRogueDir * uRogueAmp * uRogueK * cos(rp);
      h *= uWaveScale;
      grad *= uWaveScale;

      wp.y += h;
      vHeight = h;
      vWorldPos = wp;
      vNormalW = normalize(vec3(-grad.x, 1.0, -grad.y));
      vReflectCoord = uTextureMatrix * vec4(wp.x, 0.0, wp.z, 1.0);
      gl_Position = projectionMatrix * viewMatrix * vec4(wp, 1.0);
    }
  `,
  fragmentShader: /* glsl */`
    precision highp float;
    uniform float uTime;
    uniform vec3 uSunDir;
    uniform vec3 uSunColor;
    uniform sampler2D uReflection;
    uniform sampler2D uFoamTex;
    uniform sampler2D uWaterNormal;
    uniform float uFoamStrength;
    uniform float uReflectionStrength;
    uniform vec3 uCamPos;
    uniform vec3 uBoatPos;
    uniform float uBoatSpeed;
    uniform float uBoatShadow;
    uniform vec3 uFogColor;
    uniform float uFogDensity;
    uniform float uExposure;
    uniform float uOutput;
    varying vec3 vWorldPos;
    varying vec3 vNormalW;
    varying vec4 vReflectCoord;
    varying float vHeight;

    vec3 aces(vec3 x){
      return clamp((x*(2.51*x+0.03))/(x*(2.43*x+0.59)+0.14), 0.0, 1.0);
    }
    vec3 toSRGB(vec3 c){
      return mix(1.055*pow(max(c,vec3(0.0)), vec3(1.0/2.4))-0.055, c*12.92, lessThanEqual(c,vec3(0.0031308)));
    }

    void main(){
      vec2 p = vWorldPos.xz;
      vec3 toCam = uCamPos - vWorldPos;
      float dist = length(toCam);
      vec3 V = toCam / max(dist, 0.001);

      // organic capillary ripples from two scrolling normal-map layers
      float detailFade = 1.0 / (1.0 + dist * 0.012);
      vec3 n1 = texture2D(uWaterNormal, p * 0.052 + vec2(uTime * 0.006, uTime * 0.0035)).rgb * 2.0 - 1.0;
      vec3 n2 = texture2D(uWaterNormal, p * 0.138 + vec2(-uTime * 0.009, uTime * 0.0045)).rgb * 2.0 - 1.0;
      vec3 n3 = texture2D(uWaterNormal, p * 0.31 + vec2(uTime * 0.012, -uTime * 0.008)).rgb * 2.0 - 1.0;
      vec2 ripple = (n1.xy + n2.xy * 0.62 + n3.xy * 0.27) * detailFade * 0.92;
      ripple += vec2(cos(dot(p, vec2(0.71, -0.70)) * 6.3 - uTime * 3.1), sin(dot(p, vec2(-0.42, 0.91)) * 4.8 - uTime * 2.7)) * 0.014 * detailFade;

      vec3 N = normalize(vNormalW + vec3(ripple.x, 0.0, ripple.y));
      float NdV = max(dot(N, V), 0.0);
      float fresnel = 0.015 + 0.925 * pow(1.0 - NdV, 5.0);
      float distFar = smoothstep(16.0, 320.0, dist);

      // mirrored scene (soft taps to keep cloud reflections from turning into ice floes)
      vec2 ruv = vReflectCoord.xy / max(vReflectCoord.w, 0.0001);
      float j1 = texture2D(uFoamTex, p * 0.095 + vec2(uTime * 0.006, 0.0)).a;
      float j2 = texture2D(uFoamTex, p * 0.21 - vec2(0.0, uTime * 0.004)).a;
      vec2 jitter = vec2(j1, j2) - 0.5;
      vec2 distortion = N.xz * mix(0.065, 0.26, distFar);
      ruv += distortion + jitter * 0.02 * distFar;
      float blur = 1.0 + distFar * 10.0;
      vec2 texel = vec2(1.1, 1.8) / vec2(1024.0, 576.0) * blur;
      vec3 refl = texture2D(uReflection, clamp(ruv, 0.001, 0.999)).rgb * 0.26;
      refl += texture2D(uReflection, clamp(ruv + vec2(texel.x, 0.0), 0.001, 0.999)).rgb * 0.14;
      refl += texture2D(uReflection, clamp(ruv - vec2(texel.x, 0.0), 0.001, 0.999)).rgb * 0.14;
      refl += texture2D(uReflection, clamp(ruv + vec2(0.0, texel.y), 0.001, 0.999)).rgb * 0.14;
      refl += texture2D(uReflection, clamp(ruv - vec2(0.0, texel.y), 0.001, 0.999)).rgb * 0.14;
      refl += texture2D(uReflection, clamp(ruv + texel, 0.001, 0.999)).rgb * 0.045;
      refl += texture2D(uReflection, clamp(ruv - texel, 0.001, 0.999)).rgb * 0.045;
      refl += texture2D(uReflection, clamp(ruv + vec2(texel.x, -texel.y), 0.001, 0.999)).rgb * 0.045;
      refl += texture2D(uReflection, clamp(ruv + vec2(-texel.x, texel.y), 0.001, 0.999)).rgb * 0.045;
      refl = mix(refl, vec3(0.09, 0.135, 0.145), 0.07);
      refl = refl / (1.0 + refl * 0.78);

      // water body
      float crest = smoothstep(-0.22, 0.95, vHeight);
      vec3 deep = vec3(0.004, 0.019, 0.027);
      vec3 shallow = vec3(0.019, 0.086, 0.094);
      vec3 body = mix(deep, shallow, 0.42 + crest * 0.58);
      body += vec3(0.015, 0.075, 0.067) * crest * pow(max(dot(-uSunDir, V), 0.0), 2.0) * 0.8;
      body += vec3(0.015, 0.038, 0.043);

      // boat contact shadow
      float sd = distance(p, uBoatPos.xz);
      float shadow = smoothstep(7.5, 1.0, sd) * uBoatShadow;
      body *= 1.0 - shadow * 0.36;

      // wind streaks / foam texture
      float f1 = texture2D(uFoamTex, p * 0.085 + vec2(uTime * 0.0035, -uTime * 0.0018)).a;
      float f2 = texture2D(uFoamTex, p * 0.24 + vec2(-uTime * 0.008, uTime * 0.004)).a;
      float foamNoise = f1 * 0.7 + f2 * 0.5;
      float foamMask = smoothstep(0.34, 0.7, foamNoise);
      float steep = clamp(length(vNormalW.xz) * 4.4, 0.0, 1.0);
      float crestFoam = smoothstep(0.56, 1.12, vHeight) * foamMask * (0.18 + steep * 0.78);
      crestFoam += smoothstep(0.66, 0.92, foamNoise) * smoothstep(0.78, 1.12, crest) * 0.24;
      crestFoam += smoothstep(0.86, 0.995, steep) * foamMask * 0.14;
      crestFoam = clamp(crestFoam, 0.0, 1.0) * uFoamStrength;

      // churn around the hull
      float boatDist = distance(p, uBoatPos.xz);
      float hullRing = smoothstep(4.7, 2.2, boatDist) * smoothstep(0.8, 1.9, boatDist);
      float hullFoam = hullRing * smoothstep(0.34, 0.74, foamNoise) * (0.38 + clamp(uBoatSpeed * 0.03, 0.0, 0.62));
      crestFoam = clamp(crestFoam + hullFoam * 0.58 * uFoamStrength, 0.0, 1.0);

      vec3 foamColor = vec3(0.87, 0.94, 0.94);
      foamColor *= mix(0.5, 1.0, smoothstep(-0.1, 0.35, vHeight));

      vec3 col = mix(body, refl * vec3(0.9, 0.96, 1.0), fresnel * uReflectionStrength);
      col = mix(col, foamColor, crestFoam * 0.62);

      // sun specular + glitter
      vec3 H = normalize(uSunDir + V);
      float NdH = max(dot(N, H), 0.0);
      float spec = pow(NdH, 1100.0) * 42.0 + pow(NdH, 150.0) * 0.55 + pow(NdH, 24.0) * 0.035;
      float glitter = smoothstep(0.58, 0.82, f2) * pow(NdH, 260.0) * 2.2;
      col += uSunColor * (spec + glitter) * (0.9 + crestFoam * 0.2);

      // rim light on crests
      col += vec3(0.05, 0.14, 0.13) * crest * pow(1.0 - NdV, 2.0) * 0.65;

      // distance fog
      float fog = 1.0 - exp(-pow(dist * uFogDensity, 2.0));
      col = mix(col, uFogColor, clamp(fog, 0.0, 1.0));

      if (uOutput > 0.5) {
        col = aces(col * uExposure);
        col = toSRGB(col);
      }
      gl_FragColor = vec4(col, 1.0);
    }
  `
});

const oceanGeo = createOceanGeometry();
const ocean = new THREE.Mesh(oceanGeo, waterMaterial);
ocean.frustumCulled = false;
ocean.renderOrder = 1;
scene.add(ocean);

/* ── wake / particle system ───────────────────────────────────────────── */
class ParticlePool {
  constructor(max, texture, blending, baseColor = 0xffffff) {
    this.max = max;
    this.particles = [];
    this.positions = new Float32Array(max * 3);
    this.colors = new Float32Array(max * 3);
    this.sizes = new Float32Array(max);
    this.alphas = new Float32Array(max);
    const geo = new THREE.BufferGeometry();
    geo.setAttribute('position', new THREE.BufferAttribute(this.positions, 3).setUsage(THREE.DynamicDrawUsage));
    geo.setAttribute('aColor', new THREE.BufferAttribute(this.colors, 3).setUsage(THREE.DynamicDrawUsage));
    geo.setAttribute('aSize', new THREE.BufferAttribute(this.sizes, 1).setUsage(THREE.DynamicDrawUsage));
    geo.setAttribute('aAlpha', new THREE.BufferAttribute(this.alphas, 1).setUsage(THREE.DynamicDrawUsage));
    this.geometry = geo;
    this.material = new THREE.ShaderMaterial({
      uniforms: {
        uTex: { value: texture },
        uScale: { value: 700 },
        uExposure: { value: 1.02 }
      },
      transparent: true,
      depthWrite: false,
      blending,
      vertexShader: /* glsl */`
        attribute vec3 aColor;
        attribute float aSize;
        attribute float aAlpha;
        uniform float uScale;
        varying vec3 vColor;
        varying float vAlpha;
        void main(){
          vec4 mv = modelViewMatrix * vec4(position, 1.0);
          gl_Position = projectionMatrix * mv;
          gl_PointSize = clamp(aSize * uScale / max(1.0, -mv.z), 1.0, 512.0);
          vColor = aColor;
          vAlpha = aAlpha;
        }
      `,
      fragmentShader: /* glsl */`
        precision highp float;
        uniform sampler2D uTex;
        uniform float uExposure;
        varying vec3 vColor;
        varying float vAlpha;
        vec3 aces(vec3 x){ return clamp((x*(2.51*x+0.03))/(x*(2.43*x+0.59)+0.14),0.0,1.0); }
        vec3 toSRGB(vec3 c){ return mix(1.055*pow(max(c,vec3(0.0)),vec3(1.0/2.4))-0.055,c*12.92,lessThanEqual(c,vec3(0.0031308))); }
        void main(){
          vec4 tex = texture2D(uTex, gl_PointCoord);
          float a = tex.a * vAlpha;
          if (a < 0.008) discard;
          vec3 c = aces(vColor * uExposure);
          c = toSRGB(c);
          gl_FragColor = vec4(c, a);
        }
      `
    });
    this.points = new THREE.Points(geo, this.material);
    this.points.frustumCulled = false;
    this.points.renderOrder = 5;
    scene.add(this.points);
    this.points.visible = true;
  }

  spawn(o) {
    if (this.particles.length >= this.max) return;
    this.particles.push({
      x: o.x, y: o.y, z: o.z,
      vx: o.vx || 0, vy: o.vy || 0, vz: o.vz || 0,
      life: o.life || 1, age: 0,
      size: o.size || 1, grow: o.grow || 0,
      gravity: o.gravity || 0, drag: o.drag || 0,
      snap: !!o.snap, alpha: o.alpha ?? 0.7,
      color: o.color ? new THREE.Color(o.color) : new THREE.Color(0xffffff),
      fadeIn: o.fadeIn ?? 0.08
    });
  }

  update(dt, t) {
    const arr = this.particles;
    for (let i = arr.length - 1; i >= 0; i--) {
      const p = arr[i];
      p.age += dt;
      if (p.age >= p.life) { arr.splice(i, 1); continue; }
      const drag = Math.exp(-p.drag * dt);
      p.vx *= drag; p.vz *= drag;
      p.vy = p.vy * drag + p.gravity * dt;
      p.x += p.vx * dt;
      p.y += p.vy * dt;
      p.z += p.vz * dt;
      if (p.snap) {
        const wh = waveHeight(p.x, p.z, t);
        if (p.y < wh + 0.03) { p.y = wh + 0.03; p.vy = 0; }
      }
      p.size += p.grow * dt;
    }
    const n = arr.length;
    for (let i = 0; i < n; i++) {
      const p = arr[i];
      const i3 = i * 3;
      this.positions[i3] = p.x; this.positions[i3 + 1] = p.y; this.positions[i3 + 2] = p.z;
      this.colors[i3] = p.color.r; this.colors[i3 + 1] = p.color.g; this.colors[i3 + 2] = p.color.b;
      this.sizes[i] = Math.max(0.01, p.size);
      const lifeT = p.age / p.life;
      const fadeIn = smoothstep(0, p.fadeIn, p.age);
      const fadeOut = 1 - smoothstep(0.55, 1.0, lifeT);
      this.alphas[i] = p.alpha * fadeIn * fadeOut;
    }
    this.geometry.setDrawRange(0, n);
    this.geometry.attributes.position.needsUpdate = true;
    this.geometry.attributes.aColor.needsUpdate = true;
    this.geometry.attributes.aSize.needsUpdate = true;
    this.geometry.attributes.aAlpha.needsUpdate = true;
  }

  clear() {
    this.particles.length = 0;
    this.geometry.setDrawRange(0, 0);
  }
}

TEX.soft = makeSoftSpriteTexture(128, false);
const sprayPool = new ParticlePool(1600, TEX.soft, THREE.NormalBlending);
const firePool = new ParticlePool(900, TEX.soft, THREE.AdditiveBlending);
const smokePool = new ParticlePool(500, TEX.soft, THREE.NormalBlending);
sprayPool.material.depthTest = false;
sprayPool.material.needsUpdate = true;
sprayPool.points.renderOrder = 6;

// A continuous foam ribbon gives the boat a proper turbulent wake.
class WakeTrail {
  constructor(max = 110) {
    this.max = max;
    this.points = [];
    this.positions = new Float32Array(max * 6 * 3);
    this.colors = new Float32Array(max * 6 * 4);
    this.uvs = new Float32Array(max * 6 * 2);
    const indices = [];
    for (let ribbon = 0; ribbon < 3; ribbon++) {
      for (let i = 0; i < max - 1; i++) {
        const a = i * 6 + ribbon * 2, b = a + 1, c = a + 6, d = c + 1;
        indices.push(a, c, b, b, c, d);
      }
    }
    const geo = new THREE.BufferGeometry();
    geo.setAttribute('position', new THREE.BufferAttribute(this.positions, 3).setUsage(THREE.DynamicDrawUsage));
    geo.setAttribute('color', new THREE.BufferAttribute(this.colors, 4).setUsage(THREE.DynamicDrawUsage));
    geo.setAttribute('uv', new THREE.BufferAttribute(this.uvs, 2).setUsage(THREE.DynamicDrawUsage));
    geo.setIndex(indices);
    geo.setDrawRange(0, 0);
    this.geometry = geo;
    this.material = new THREE.MeshBasicMaterial({
      map: TEX.foam, color: 0xffffff, transparent: true, opacity: 0.9,
      depthWrite: false, depthTest: true, side: THREE.DoubleSide,
      vertexColors: true, toneMapped: false
    });
    this.mesh = new THREE.Mesh(geo, this.material);
    this.mesh.frustumCulled = false;
    this.mesh.renderOrder = 4;
    scene.add(this.mesh);
  }
  add(x, z, rx, rz, speed, right) {
    this.points.unshift({ x, z, rx, rz, speed, age: 0 });
    if (this.points.length > this.max) this.points.pop();
  }
  update(dt, t) {
    const pts = this.points;
    for (let i = pts.length - 1; i >= 0; i--) {
      pts[i].age += dt;
      if (pts[i].age > 6.5) pts.splice(i, 1);
    }
    const n = pts.length;
    if (n < 2) { this.geometry.setDrawRange(0, 0); return; }
    for (let i = 0; i < n; i++) {
      const p = pts[i];
      const ageN = clamp(p.age / 6.5, 0, 1);
      const width = 0.68 + ageN * 2.6 + Math.min(p.speed * 0.02, 0.55);
      const y = waveHeight(p.x, p.z, t) + 0.035;
      const a = (1 - ageN) * 0.94 + 0.06;
      const armOffset = Math.sqrt(ageN) * 8.5;
      const armWidth = 0.34 + ageN * 1.65;
      const base = i * 18;
      // central churn
      const setV = (o, ox, oz, alpha, v) => {
        this.positions[base + o] = ox;
        this.positions[base + o + 1] = y;
        this.positions[base + o + 2] = oz;
        const ci = i * 24 + (o / 3) * 4;
        this.colors[ci] = 0.92; this.colors[ci + 1] = 0.98; this.colors[ci + 2] = 0.98; this.colors[ci + 3] = alpha;
        const ui = i * 12 + (o / 3) * 2;
        this.uvs[ui] = o % 6 === 0 ? 0 : 1;
        this.uvs[ui + 1] = v;
      };
      setV(0, p.x - p.rx * width, p.z - p.rz * width, a, i * 0.32);
      setV(3, p.x + p.rx * width, p.z + p.rz * width, a, i * 0.32);
      // port and starboard arms of the Kelvin wake
      setV(6, p.x - p.rx * (armOffset + armWidth), p.z - p.rz * (armOffset + armWidth), a * 0.78, i * 0.25);
      setV(9, p.x - p.rx * Math.max(0, armOffset - armWidth), p.z - p.rz * Math.max(0, armOffset - armWidth), a * 0.78, i * 0.25);
      setV(12, p.x + p.rx * Math.max(0, armOffset - armWidth), p.z + p.rz * Math.max(0, armOffset - armWidth), a * 0.78, i * 0.25);
      setV(15, p.x + p.rx * (armOffset + armWidth), p.z + p.rz * (armOffset + armWidth), a * 0.78, i * 0.25);
    }
    this.geometry.setDrawRange(0, (n - 1) * 18);
    this.geometry.attributes.position.needsUpdate = true;
    this.geometry.attributes.color.needsUpdate = true;
    this.geometry.attributes.uv.needsUpdate = true;
  }
  clear() { this.points.length = 0; this.geometry.setDrawRange(0, 0); }
}
const wakeTrail = new WakeTrail();

function emitSpray(x, y, z, count, spread, up, size, color = 0xdff4f6) {
  for (let i = 0; i < count; i++) {
    const a = rand(0, TAU), r = rand(0, spread);
    sprayPool.spawn({
      x, y: y + rand(-0.1, 0.35), z,
      vx: Math.cos(a) * r, vy: rand(up * 0.45, up), vz: Math.sin(a) * r,
      life: rand(0.55, 1.35), size: rand(size * 0.55, size * 1.4), grow: rand(0.1, 0.5),
      gravity: -9.8, drag: 0.8, alpha: rand(0.22, 0.62), color
    });
  }
}
function emitFire(x, y, z, count, power, colorA = 0xffdd88, colorB = 0xff4a1a) {
  const cA = new THREE.Color(colorA), cB = new THREE.Color(colorB);
  for (let i = 0; i < count; i++) {
    const a = rand(0, TAU), b = rand(-0.3, 1.0);
    const sp = rand(0.2, power);
    firePool.spawn({
      x, y, z,
      vx: Math.cos(a) * Math.cos(b) * sp, vy: Math.sin(b) * sp + rand(0.2, 1.8), vz: Math.sin(a) * Math.cos(b) * sp,
      life: rand(0.2, 0.65), size: rand(0.25, 1.0) * (power / 7), grow: rand(0.4, 2.0),
      gravity: -2.5, drag: 1.2, alpha: rand(0.5, 1.0),
      color: Math.random() < 0.5 ? cA : cB
    });
  }
}
function emitSmoke(x, y, z, count, power, dark = 0x1a1d1c) {
  const col = new THREE.Color(dark);
  for (let i = 0; i < count; i++) {
    const a = rand(0, TAU);
    smokePool.spawn({
      x: x + rand(-0.6, 0.6), y: y + rand(-0.4, 0.8), z: z + rand(-0.6, 0.6),
      vx: Math.cos(a) * rand(0.2, power * 0.4), vy: rand(0.15, 0.85), vz: Math.sin(a) * rand(0.2, power * 0.4),
      life: rand(1.2, 2.8), size: rand(1.0, 2.4), grow: rand(0.7, 1.8),
      gravity: 0.35, drag: 0.35, alpha: rand(0.1, 0.25), color: col
    });
  }
}
function emitWake(x, z, vx, vz, speed) {
  const t = clockTime;
  const n = clamp(Math.floor(speed * 0.1) + 1, 1, 3);
  for (let i = 0; i < n; i++) {
    const side = Math.random() < 0.5 ? -1 : 1;
    const px = x + side * rand(0.2, 1.1);
    const pz = z + rand(-0.25, 0.25);
    sprayPool.spawn({
      x: px, y: waveHeight(px, pz, t) + 0.12, z: pz,
      vx: vx + side * rand(0.8, 2.8), vy: rand(0.08, 0.5), vz: vz + rand(-0.5, 0.5),
      life: rand(1.8, 3.6), size: rand(0.55, 1.55), grow: rand(0.18, 0.5),
      gravity: 0, drag: 1.15, snap: true, alpha: rand(0.16, 0.42), color: Math.random() < 0.2 ? 0xc1eef2 : 0xe8f6f6
    });
  }
}

/* ── flare sprites / explosions ───────────────────────────────────────── */
const fxSprites = [];
function spawnFlash(x, y, z, scale, color = 0xffb15c, life = 0.32, opacity = 0.9) {
  const mat = new THREE.SpriteMaterial({
    map: TEX.sun, color, transparent: true, opacity,
    blending: THREE.AdditiveBlending, depthWrite: false, depthTest: true, fog: false
  });
  const s = new THREE.Sprite(mat);
  s.position.set(x, y, z);
  s.scale.set(scale, scale, 1);
  s.renderOrder = 8;
  scene.add(s);
  fxSprites.push({ sprite: s, life, age: 0, startScale: scale, opacity, kind: 'flash' });
  return s;
}

const ringGeo = new THREE.RingGeometry(0.92, 1.0, 48);
function spawnShockRing(x, z, scale, color = 0xdff7fa, life = 0.8) {
  const mat = new THREE.MeshBasicMaterial({
    color, transparent: true, opacity: 0.5, blending: THREE.AdditiveBlending,
    side: THREE.DoubleSide, depthWrite: false
  });
  const mesh = new THREE.Mesh(ringGeo, mat);
  mesh.rotation.x = -Math.PI / 2;
  mesh.position.set(x, waveHeight(x, z, clockTime) + 0.12, z);
  mesh.scale.setScalar(scale * 0.2);
  scene.add(mesh);
  fxSprites.push({ sprite: mesh, life, age: 0, startScale: scale, opacity: 0.5, kind: 'ring' });
}

function updateFX(dt) {
  for (let i = fxSprites.length - 1; i >= 0; i--) {
    const fx = fxSprites[i];
    fx.age += dt;
    const t = clamp(fx.age / fx.life, 0, 1);
    if (t >= 1) {
      scene.remove(fx.sprite);
      fx.sprite.material.dispose();
      fxSprites.splice(i, 1);
      continue;
    }
    if (fx.kind === 'ring') {
      const s = fx.startScale * (0.25 + t * 1.2);
      fx.sprite.scale.setScalar(s);
      fx.sprite.material.opacity = fx.opacity * (1 - t);
    } else {
      const s = fx.startScale * (0.55 + t * 1.5);
      fx.sprite.scale.set(s, s, 1);
      fx.sprite.material.opacity = fx.opacity * Math.pow(1 - t, 1.8);
    }
  }
}

function bigExplosion(x, y, z, size = 1) {
  spawnFlash(x, y + 0.2, z, 9 * size, 0xffb15c, 0.45, 0.95);
  spawnFlash(x, y + 0.4, z, 3 * size, 0xfff1c9, 0.2, 0.85);
  emitFire(x, y + 0.2, z, Math.round(55 * size), 10 * size);
  emitSmoke(x, y + 0.6, z, Math.round(30 * size), 4 * size, 0x171a18);
  emitSpray(x, waveHeight(x, z, clockTime) + 0.1, z, Math.round(35 * size), 7 * size, 8 * size, 0.75 * size);
  spawnShockRing(x, z, 9 * size, 0xcaedf2, 0.95);
}

/* ── boat construction ────────────────────────────────────────────────── */
TEX.teak = generateTeakTexture();
TEX.skull = makeSkullDecal();
TEX.name = makeNameDecal('NEREID', 'PATROL / 07');

function makeHullGeometry(stations) {
  const rings = [];
  for (const s of stations) {
    const half = [
      [0, -s.depth],
      [s.w * 0.62, -s.depth * 0.58],
      [s.w * 0.98, -0.13],
      [s.w * 1.02, 0.025],
      [s.w * 1.005, 0.16],
      [s.w * 0.92, s.deck]
    ];
    const ring = [];
    for (const p of half) ring.push(p);
    ring.push([0, s.deck + 0.055]);
    for (let i = half.length - 1; i >= 1; i--) ring.push([-half[i][0], half[i][1]]);
    rings.push({ z: s.z, pts: ring });
  }

  const positions = [], uvs = [];
  const indicesByMat = [[], [], []];
  let base = 0;
  const matForBand = [0, 0, 0, 1, 2, 2, 2, 2, 1, 0, 0, 0];

  for (let s = 0; s < rings.length; s++) {
    const ring = rings[s];
    for (let i = 0; i < ring.pts.length; i++) {
      positions.push(ring.pts[i][0], ring.pts[i][1], ring.z);
      uvs.push(i / ring.pts.length, s / (rings.length - 1));
    }
  }

  for (let s = 0; s < rings.length - 1; s++) {
    const r0 = s * 12;
    const r1 = (s + 1) * 12;
    for (let b = 0; b < 12; b++) {
      const b2 = (b + 1) % 12;
      const a = r0 + b, bb = r0 + b2, c = r1 + b2, d = r1 + b;
      const list = indicesByMat[matForBand[b]];
      list.push(a, bb, c, a, c, d);
    }
  }

  const geo = new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
  geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
  const indices = [];
  let start = 0;
  indicesByMat.forEach((list, i) => {
    indices.push(...list);
    geo.addGroup(start, list.length, i);
    start += list.length;
  });
  geo.setIndex(indices);
  geo.computeVertexNormals();
  base = 0;
  return { geo, rings, stations };
}

function makeDeckGeometry(stations) {
  const positions = [], uvs = [], indices = [];
  const row = (s) => {
    const y = s.deck + 0.02;
    return [
      [-s.w * 0.92, y],
      [0, s.deck + 0.075],
      [s.w * 0.92, y]
    ];
  };
  for (let s = 0; s < stations.length; s++) {
    const st = stations[s];
    const p = row(st);
    for (let i = 0; i < 3; i++) {
      positions.push(p[i][0], p[i][1], st.z);
      uvs.push(i * 0.5, (st.z + 4) / 8.5);
    }
  }
  for (let s = 0; s < stations.length - 1; s++) {
    const r0 = s * 3, r1 = (s + 1) * 3;
    indices.push(r0, r1, r0 + 1, r0 + 1, r1, r1 + 1);
    indices.push(r0 + 1, r1 + 1, r0 + 2, r0 + 2, r1 + 1, r1 + 2);
  }
  const geo = new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
  geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
  geo.setIndex(indices);
  geo.computeVertexNormals();
  return geo;
}

function tubeBetween(a, b, radius, mat) {
  const dir = new THREE.Vector3().subVectors(b, a);
  const len = dir.length();
  const geo = new THREE.CylinderGeometry(radius, radius, len, 8, 1);
  const mesh = new THREE.Mesh(geo, mat);
  mesh.position.copy(a).add(b).multiplyScalar(0.5);
  mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.normalize());
  return mesh;
}

const matLib = {};
function initMaterials() {
  matLib.white = new THREE.MeshPhysicalMaterial({ color: 0xf1eee4, roughness: 0.29, metalness: 0.08, clearcoat: 0.8, clearcoatRoughness: 0.17, envMapIntensity: 0.95 });
  matLib.navy = new THREE.MeshStandardMaterial({ color: 0x071e2a, roughness: 0.32, metalness: 0.3, envMapIntensity: 0.9 });
  matLib.red = new THREE.MeshStandardMaterial({ color: 0x921d23, roughness: 0.33, metalness: 0.18, envMapIntensity: 0.85 });
  matLib.teak = new THREE.MeshStandardMaterial({ map: TEX.teak, color: 0xd9b58e, roughness: 0.62, metalness: 0.02 });
  matLib.chrome = new THREE.MeshStandardMaterial({ color: 0xdfe5e8, roughness: 0.11, metalness: 1.0, envMapIntensity: 1.4 });
  matLib.gun = new THREE.MeshStandardMaterial({ color: 0x1b2326, roughness: 0.28, metalness: 0.9, envMapIntensity: 1.25 });
  matLib.black = new THREE.MeshStandardMaterial({ color: 0x070c0f, roughness: 0.45, metalness: 0.45, envMapIntensity: 0.8 });
  matLib.glass = new THREE.MeshPhysicalMaterial({ color: 0x06151b, roughness: 0.03, metalness: 0.25, transparent: true, opacity: 0.56, envMapIntensity: 1.8, side: THREE.DoubleSide });
  matLib.cushion = new THREE.MeshStandardMaterial({ color: 0x493527, roughness: 0.62, metalness: 0.02 });
  matLib.enemyHull = new THREE.MeshStandardMaterial({ color: 0x191d1e, roughness: 0.55, metalness: 0.45, envMapIntensity: 0.85 });
  matLib.enemyRed = new THREE.MeshStandardMaterial({ color: 0x5c1013, roughness: 0.5, metalness: 0.25 });
  matLib.enemyLower = new THREE.MeshStandardMaterial({ color: 0x0a0f12, roughness: 0.62, metalness: 0.3 });
  matLib.enemyDeck = new THREE.MeshStandardMaterial({ color: 0x27211b, roughness: 0.8, metalness: 0.07 });
  matLib.rust = new THREE.MeshStandardMaterial({ color: 0x3c2118, roughness: 0.86, metalness: 0.25 });
}

const STATIONS = [
  { z: -3.65, w: 1.04, depth: 0.56, deck: 0.35 },
  { z: -2.95, w: 1.22, depth: 0.68, deck: 0.39 },
  { z: -1.65, w: 1.33, depth: 0.75, deck: 0.42 },
  { z: -0.25, w: 1.32, depth: 0.75, deck: 0.42 },
  { z: 1.15, w: 1.23, depth: 0.71, deck: 0.39 },
  { z: 2.35, w: 1.00, depth: 0.63, deck: 0.34 },
  { z: 3.35, w: 0.64, depth: 0.49, deck: 0.26 },
  { z: 4.12, w: 0.10, depth: 0.19, deck: 0.09 }
];
const HULL = makeHullGeometry(STATIONS);

function makeTurret(enemy = false) {
  const yaw = new THREE.Group();
  yaw.name = 'turretYaw';
  const scale = enemy ? 0.8 : 1;
  const base = new THREE.Mesh(new THREE.CylinderGeometry(0.52 * scale, 0.6 * scale, 0.18 * scale, 20), matLib.gun);
  base.position.y = 0.08 * scale;
  base.receiveShadow = true; base.castShadow = true;
  yaw.add(base);
  const housing = new THREE.Mesh(new RoundedBoxGeometry(0.96 * scale, 0.42 * scale, 1.02 * scale, 3, 0.09 * scale), matLib.gun);
  housing.position.set(0, 0.37 * scale, 0);
  housing.castShadow = true; housing.receiveShadow = true;
  yaw.add(housing);
  const pitch = new THREE.Group();
  pitch.name = 'barrelPitch';
  pitch.position.set(0, 0.4 * scale, 0.36 * scale);
  yaw.add(pitch);
  const barrelMat = matLib.gun;
  const bLen = 1.75 * scale;
  for (const side of [-1, 1]) {
    const barrel = new THREE.Mesh(new THREE.CylinderGeometry(0.052 * scale, 0.062 * scale, bLen, 10), barrelMat);
    barrel.rotation.x = Math.PI / 2;
    barrel.position.set(side * 0.13 * scale, 0.05 * scale, bLen * 0.52);
    barrel.castShadow = true;
    pitch.add(barrel);
    const brake = new THREE.Mesh(new THREE.CylinderGeometry(0.09 * scale, 0.09 * scale, 0.2 * scale, 10), matLib.gun);
    brake.rotation.x = Math.PI / 2;
    brake.position.set(side * 0.13 * scale, 0.05 * scale, bLen * 1.02);
    pitch.add(brake);
  }
  const sight = new THREE.Mesh(new THREE.BoxGeometry(0.22 * scale, 0.16 * scale, 0.13 * scale), matLib.black);
  sight.position.set(0.28 * scale, 0.2 * scale, -0.1 * scale);
  yaw.add(sight);
  const muzzle = new THREE.Object3D();
  muzzle.name = 'muzzleTarget';
  muzzle.position.set(0, 0.05 * scale, bLen * 1.1);
  pitch.add(muzzle);
  yaw.userData.pitch = pitch;
  yaw.userData.muzzle = muzzle;
  yaw.userData.scale = scale;
  return yaw;
}

function makeFlag(color = 0xab1f24) {
  const geo = new THREE.PlaneGeometry(1.55, 0.82, 14, 5);
  geo.translate(-0.775, 0.41, 0);
  const mat = new THREE.MeshStandardMaterial({ color, roughness: 0.7, metalness: 0.0, side: THREE.DoubleSide });
  const mesh = new THREE.Mesh(geo, mat);
  mesh.castShadow = true;
  mesh.userData.base = geo.attributes.position.array.slice();
  mesh.userData.time = Math.random() * 10;
  return mesh;
}

function buildBoat(type = 'player') {
  const enemy = type === 'enemy';
  const root = new THREE.Group();
  const hullMat = enemy ? matLib.enemyHull : matLib.white;
  const lowerMat = enemy ? matLib.enemyLower : matLib.navy;
  const stripeMat = enemy ? matLib.enemyRed : matLib.red;
  const hull = new THREE.Mesh(HULL.geo, [lowerMat, stripeMat, hullMat]);
  hull.castShadow = true; hull.receiveShadow = true;
  root.add(hull);

  const deck = new THREE.Mesh(makeDeckGeometry(STATIONS), enemy ? matLib.enemyDeck : matLib.teak);
  deck.position.y = 0.008;
  deck.receiveShadow = true; deck.castShadow = true;
  root.add(deck);

  // transom / stern plate
  const stern = STATIONS[0];
  const shape = new THREE.Shape();
  const half = [
    [0, -stern.depth],
    [stern.w * 0.62, -stern.depth * 0.58],
    [stern.w * 0.98, -0.13],
    [stern.w * 1.02, 0.025],
    [stern.w * 1.005, 0.16],
    [stern.w * 0.92, stern.deck],
    [0, stern.deck + 0.055]
  ];
  const ring = [...half, ...half.slice(1).reverse().map(p => [-p[0], p[1]])];
  shape.moveTo(ring[0][0], ring[0][1]);
  for (let i = 1; i < ring.length; i++) shape.lineTo(ring[i][0], ring[i][1]);
  shape.closePath();
  const transom = new THREE.Mesh(new THREE.ShapeGeometry(shape), hullMat);
  transom.rotation.y = Math.PI;
  transom.position.z = stern.z - 0.005;
  transom.castShadow = true; transom.receiveShadow = true;
  root.add(transom);

  if (!enemy) {
    const stripe = new THREE.Mesh(new THREE.BoxGeometry(1.7, 0.105, 0.018), matLib.red);
    stripe.position.set(0, 0.13, stern.z - 0.018);
    root.add(stripe);
    const decal = new THREE.Mesh(new THREE.PlaneGeometry(0.72, 0.225), new THREE.MeshBasicMaterial({ map: TEX.name, transparent: true, depthWrite: false, toneMapped: false }));
    decal.position.set(0, 0.53, stern.z - 0.03);
    decal.rotation.y = Math.PI;
    root.add(decal);
  } else {
    const stripe = new THREE.Mesh(new THREE.BoxGeometry(1.8, 0.12, 0.02), matLib.enemyRed);
    stripe.position.set(0, 0.05, stern.z - 0.02);
    root.add(stripe);
  }

  // rub rails
  for (const side of [-1, 1]) {
    const pts = [];
    for (let i = 1; i < STATIONS.length; i++) {
      const s = STATIONS[i];
      pts.push(new THREE.Vector3(side * s.w * 0.925, s.deck + 0.02, s.z));
    }
    const curve = new THREE.CatmullRomCurve3(pts);
    const rail = new THREE.Mesh(new THREE.TubeGeometry(curve, 24, 0.035, 6, false), enemy ? matLib.rust : matLib.black);
    rail.castShadow = true;
    root.add(rail);
  }

  // deck hardware
  const consoleZ = enemy ? 0.15 : 0.05;
  const consoleBase = new THREE.Mesh(new RoundedBoxGeometry(2.02, 0.72, 1.95, 3, 0.1), hullMat);
  consoleBase.position.set(0, 0.76, consoleZ);
  consoleBase.castShadow = true; consoleBase.receiveShadow = true;
  root.add(consoleBase);

  // dash / windshield
  const dash = new THREE.Mesh(new RoundedBoxGeometry(1.9, 0.07, 0.64, 2, 0.03), matLib.black);
  dash.position.set(0, 1.14, consoleZ + 0.66);
  dash.rotation.x = -0.22;
  root.add(dash);

  const windshield = new THREE.Mesh(new THREE.PlaneGeometry(1.82, 0.72), matLib.glass);
  windshield.position.set(0, 1.49, consoleZ + 1.0);
  windshield.rotation.x = -0.28;
  windshield.castShadow = false;
  root.add(windshield);

  for (const side of [-1, 1]) {
    const sideGlass = new THREE.Mesh(new THREE.PlaneGeometry(0.9, 0.56), matLib.glass);
    sideGlass.position.set(side * 1.015, 1.42, consoleZ + 0.55);
    sideGlass.rotation.y = side * Math.PI / 2;
    sideGlass.rotation.z = side * 0.07;
    root.add(sideGlass);
  }

  // low windscreen frame
  const frame = new THREE.Mesh(new THREE.BoxGeometry(1.94, 0.035, 0.035), matLib.chrome);
  frame.position.set(0, 1.85, consoleZ + 0.9);
  root.add(frame);

  // seats
  if (!enemy) {
    for (const side of [-1, 1]) {
      const seat = new THREE.Group();
      const base = new THREE.Mesh(new THREE.CylinderGeometry(0.19, 0.23, 0.48, 10), matLib.gun);
      base.position.y = 0.25;
      base.castShadow = true;
      seat.add(base);
      const cushion = new THREE.Mesh(new RoundedBoxGeometry(0.62, 0.16, 0.6, 3, 0.07), matLib.cushion);
      cushion.position.y = 0.55;
      cushion.castShadow = true;
      seat.add(cushion);
      const back = new THREE.Mesh(new RoundedBoxGeometry(0.62, 0.68, 0.16, 3, 0.07), matLib.cushion);
      back.position.set(0, 0.86, -0.24);
      back.rotation.x = -0.16;
      back.castShadow = true;
      seat.add(back);
      seat.position.set(side * 0.52, 0.36, -1.07);
      root.add(seat);
    }

    // stern bench
    const bench = new THREE.Group();
    const bBase = new THREE.Mesh(new RoundedBoxGeometry(1.9, 0.19, 0.64, 3, 0.07), hullMat);
    bBase.position.y = 0.54;
    bBase.castShadow = true;
    bench.add(bBase);
    const bCushion = new THREE.Mesh(new RoundedBoxGeometry(1.68, 0.15, 0.5, 3, 0.055), matLib.cushion);
    bCushion.position.y = 0.68;
    bench.add(bCushion);
    bench.position.set(0, 0, -2.55);
    root.add(bench);

    // T-top
    const top = new THREE.Mesh(new RoundedBoxGeometry(2.6, 0.075, 2.22, 3, 0.055), matLib.white);
    top.position.set(0, 2.18, consoleZ - 0.02);
    top.castShadow = true; top.receiveShadow = true;
    root.add(top);
    const lightBar = new THREE.Mesh(new THREE.BoxGeometry(1.5, 0.026, 0.055), new THREE.MeshBasicMaterial({ color: 0xffdaa4, toneMapped: false }));
    lightBar.position.set(0, 2.105, consoleZ + 0.1);
    root.add(lightBar);

    const postPts = [
      [-1.14, 2.14, consoleZ + 0.87], [1.14, 2.14, consoleZ + 0.87],
      [-1.14, 2.14, consoleZ - 0.94], [1.14, 2.14, consoleZ - 0.94]
    ];
    for (const p of postPts) {
      const baseP = new THREE.Vector3(p[0] * 0.93, 1.1, p[2]);
      const topP = new THREE.Vector3(p[0], p[1], p[2]);
      const post = tubeBetween(baseP, topP, 0.028, matLib.chrome);
      post.castShadow = true;
      root.add(post);
    }
    const radarBase = new THREE.Mesh(new THREE.CylinderGeometry(0.32, 0.38, 0.16, 16), matLib.black);
    radarBase.position.set(0, 2.28, consoleZ - 0.55);
    root.add(radarBase);
    const radarDome = new THREE.Mesh(new THREE.SphereGeometry(0.28, 18, 10, 0, TAU, 0, Math.PI / 2), matLib.white);
    radarDome.position.set(0, 2.34, consoleZ - 0.55);
    radarDome.scale.y = 0.72;
    root.add(radarDome);
    const radarSpin = new THREE.Group();
    radarSpin.name = 'radarSpin';
    radarSpin.position.set(0, 2.55, consoleZ - 0.55);
    const radarBar = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.035, 0.075), matLib.black);
    radarBar.position.x = 0.32;
    radarSpin.add(radarBar);
    root.add(radarSpin);

    const antenna = new THREE.Mesh(new THREE.CylinderGeometry(0.012, 0.02, 1.2, 6), matLib.black);
    antenna.position.set(0.72, 2.55, consoleZ - 0.98);
    antenna.rotation.z = -0.12;
    root.add(antenna);
    const antenna2 = antenna.clone();
    antenna2.scale.setScalar(0.78);
    antenna2.position.set(-0.82, 2.4, consoleZ - 0.9);
    antenna2.rotation.z = 0.18;
    root.add(antenna2);

    // spotlight
    const spot = new THREE.Mesh(new THREE.CylinderGeometry(0.13, 0.1, 0.34, 12), matLib.chrome);
    spot.rotation.x = Math.PI / 2;
    spot.position.set(0, 1.87, consoleZ + 0.92);
    root.add(spot);
    const lens = new THREE.Mesh(new THREE.CircleGeometry(0.085, 12), new THREE.MeshBasicMaterial({ color: 0xfff0d0, toneMapped: false }));
    lens.position.set(0, 1.87, consoleZ + 1.1);
    root.add(lens);

    // flag
    const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.022, 0.028, 1.8, 8), matLib.chrome);
    pole.position.set(-0.92, 1.15, -2.98);
    root.add(pole);
    const flag = makeFlag(0xa61e25);
    flag.name = 'flag';
    flag.position.set(-0.92, 2.0, -2.98);
    flag.rotation.y = Math.PI / 2;
    root.add(flag);

    // rails
    for (const side of [-1, 1]) {
      const pts = [];
      for (let i = 4; i < STATIONS.length; i++) {
        const s = STATIONS[i];
        pts.push(new THREE.Vector3(side * s.w * 0.9, s.deck + 0.63, s.z));
      }
      if (pts.length > 1) {
        const curve = new THREE.CatmullRomCurve3(pts);
        const rail = new THREE.Mesh(new THREE.TubeGeometry(curve, 20, 0.021, 5, false), matLib.chrome);
        rail.castShadow = true;
        root.add(rail);
        for (let i = 1; i < pts.length - 1; i++) {
          const s = STATIONS[i + 4];
          const post = new THREE.Mesh(new THREE.CylinderGeometry(0.017, 0.017, 0.62, 6), matLib.chrome);
          post.position.set(side * s.w * 0.9, s.deck + 0.34, s.z);
          root.add(post);
        }
      }
    }

    // nav lights
    const navColors = [[-1, 0xff2c1e], [1, 0x27ff79]];
    for (const [side, col] of navColors) {
      const s = STATIONS[6];
      const bulb = new THREE.Mesh(new THREE.SphereGeometry(0.055, 8, 6), new THREE.MeshBasicMaterial({ color: col, toneMapped: false }));
      bulb.position.set(side * 0.5, s.deck + 0.18, s.z - 0.05);
      root.add(bulb);
      const glow = new THREE.Sprite(new THREE.SpriteMaterial({ map: TEX.soft, color: col, transparent: true, opacity: 0.55, blending: THREE.AdditiveBlending, depthWrite: false, depthTest: true }));
      glow.scale.set(0.5, 0.5, 1);
      glow.position.copy(bulb.position);
      root.add(glow);
    }
    const sternLight = new THREE.Mesh(new THREE.SphereGeometry(0.05, 8, 6), new THREE.MeshBasicMaterial({ color: 0xfff2cf, toneMapped: false }));
    sternLight.position.set(0, 0.62, -3.62);
    root.add(sternLight);

    // bow decal / number
    const number = new THREE.Mesh(new THREE.PlaneGeometry(0.42, 0.15), new THREE.MeshBasicMaterial({ map: makeNameDecal('07', ''), transparent: true, depthWrite: false, toneMapped: false }));
    number.position.set(0.73, 0.23, 2.95);
    number.rotation.y = Math.PI / 2;
    number.rotation.z = Math.PI / 2;
    root.add(number);
  } else {
    // pirate ram + red strips
    for (const side of [-1, 1]) {
      const spike = new THREE.Mesh(new THREE.ConeGeometry(0.11, 1.05, 4), matLib.rust);
      spike.rotation.x = Math.PI / 2;
      spike.position.set(side * 0.42, 0.11, 3.95);
      spike.castShadow = true;
      root.add(spike);
      const strip = new THREE.Mesh(new THREE.BoxGeometry(0.02, 0.035, 3.2), new THREE.MeshBasicMaterial({ color: 0xff2617, toneMapped: false }));
      strip.position.set(side * 1.13, 0.2, 0.1);
      root.add(strip);
    }
    const ram = new THREE.Mesh(new THREE.BoxGeometry(0.75, 0.16, 0.55), matLib.rust);
    ram.position.set(0, 0.02, 3.82);
    ram.rotation.x = 0.18;
    ram.castShadow = true;
    root.add(ram);

    // skull decals
    for (const side of [-1, 1]) {
      const skull = new THREE.Mesh(new THREE.PlaneGeometry(0.7, 0.7), new THREE.MeshBasicMaterial({ map: TEX.skull, transparent: true, depthWrite: false, toneMapped: false, opacity: 0.68 }));
      skull.position.set(side * 1.13, 0.2, 1.45);
      skull.rotation.y = side * Math.PI / 2;
      root.add(skull);
    }

    // rough exhaust stacks / engine glow
    for (const side of [-1, 1]) {
      const stack = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.11, 0.55, 8), matLib.rust);
      stack.position.set(side * 0.76, 0.92, -3.12);
      stack.rotation.x = -0.1;
      root.add(stack);
      const glow = new THREE.Sprite(new THREE.SpriteMaterial({ map: TEX.sun, color: 0xff4b2b, transparent: true, opacity: 0.5, blending: THREE.AdditiveBlending, depthWrite: false, depthTest: true }));
      glow.scale.set(0.8, 0.8, 1);
      glow.position.set(side * 0.55, 0.24, -3.95);
      glow.name = 'engineGlow';
      root.add(glow);
    }
  }

  // outboards
  const engineCount = enemy ? 1 : 2;
  for (let i = 0; i < engineCount; i++) {
    const side = engineCount === 1 ? 0 : (i === 0 ? -0.56 : 0.56);
    const engine = new THREE.Group();
    const cowl = new THREE.Mesh(new RoundedBoxGeometry(0.54, 0.72, 0.68, 3, 0.1), enemy ? matLib.enemyRed : matLib.navy);
    cowl.position.y = 0.47;
    cowl.castShadow = true;
    engine.add(cowl);
    const top = new THREE.Mesh(new RoundedBoxGeometry(0.46, 0.1, 0.57, 2, 0.045), matLib.black);
    top.position.y = 0.86;
    engine.add(top);
    const handle = new THREE.Mesh(new THREE.BoxGeometry(0.03, 0.08, 0.38), matLib.chrome);
    handle.position.set(0, 0.52, -0.36);
    engine.add(handle);
    const shaft = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.92, 0.3), matLib.gun);
    shaft.position.y = -0.18;
    engine.add(shaft);
    const fin = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.08, 0.5), matLib.gun);
    fin.position.set(0, -0.53, -0.02);
    engine.add(fin);
    const prop = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 0.46, 3), matLib.chrome);
    prop.rotation.z = Math.PI / 2;
    prop.position.set(0, -0.62, -0.1);
    engine.add(prop);
    engine.position.set(side, 0, -3.65);
    root.add(engine);
  }

  // turret
  const turret = makeTurret(enemy);
  turret.position.set(0, enemy ? 0.44 : 0.43, enemy ? 1.9 : 2.68);
  root.add(turret);
  root.userData.turret = turret;

  if (!enemy) {
    root.scale.setScalar(1.0);
  } else {
    root.scale.setScalar(0.94);
  }

  root.traverse(o => {
    if (o.isMesh) {
      o.castShadow = true;
      o.receiveShadow = true;
    }
  });

  return root;
}

initMaterials();

const playerBoat = buildBoat('player');
scene.add(playerBoat);
playerBoat.add(cockpitLight);
cockpitLight.position.set(0, 2.0, -0.7);

let enemyTemplate = null;
function getEnemyTemplate() {
  if (!enemyTemplate) enemyTemplate = buildBoat('enemy');
  return enemyTemplate;
}

/* ── distant islands ──────────────────────────────────────────────────── */
const islands = [];
function addIsland(x, z, radius, height, seed) {
  const geo = new THREE.IcosahedronGeometry(1, 4);
  const pos = geo.attributes.position;
  const v = new THREE.Vector3();
  for (let i = 0; i < pos.count; i++) {
    v.fromBufferAttribute(pos, i);
    const n = fbm(v.x * 2.1 + seed, v.z * 2.1 - seed, 4);
    const n2 = noise2(v.x * 7 + seed * 3, v.z * 7 - seed);
    v.multiplyScalar(0.78 + n * 0.5 + n2 * 0.12);
    v.y = Math.max(v.y * 0.45, -0.2);
    pos.setXYZ(i, v.x, v.y * height, v.z);
  }
  geo.computeVertexNormals();
  const mat = new THREE.MeshStandardMaterial({ color: 0x242b2e, roughness: 0.96, metalness: 0.03 });
  const mesh = new THREE.Mesh(geo, mat);
  mesh.position.set(x, -height * 0.18, z);
  mesh.scale.set(radius, height, radius);
  mesh.castShadow = false;
  mesh.receiveShadow = false;
  scene.add(mesh);
  islands.push({ x, z, radius: radius * 0.72, mesh });
  return mesh;
}

addIsland(-640, -860, 92, 34, 3.4);
addIsland(860, -1180, 150, 49, 8.8);

// distant lighthouse on the left island
{
  const lh = new THREE.Group();
  const tower = new THREE.Mesh(new THREE.CylinderGeometry(0.55, 1.1, 7, 10), new THREE.MeshStandardMaterial({ color: 0xc9c7ba, roughness: 0.8 }));
  tower.position.y = 3.5;
  lh.add(tower);
  const cap = new THREE.Mesh(new THREE.CylinderGeometry(0.75, 0.75, 0.55, 10), new THREE.MeshStandardMaterial({ color: 0x8f1e1d, roughness: 0.55 }));
  cap.position.y = 7.1;
  lh.add(cap);
  const lamp = new THREE.Mesh(new THREE.SphereGeometry(0.4, 10, 8), new THREE.MeshBasicMaterial({ color: 0xffd596, toneMapped: false }));
  lamp.position.y = 7.45;
  lh.add(lamp);
  lh.position.set(-640, 10, -860);
  lh.scale.setScalar(1.3);
  scene.add(lh);
}

/* ── aim marker ───────────────────────────────────────────────────────── */
const aimMarker = new THREE.Mesh(
  new THREE.RingGeometry(0.34, 0.44, 24),
  new THREE.MeshBasicMaterial({ color: 0x8feaff, transparent: true, opacity: 0.55, side: THREE.DoubleSide, blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false })
);
aimMarker.rotation.x = -Math.PI / 2;
aimMarker.visible = false;
scene.add(aimMarker);

/* ── watercraft physics ───────────────────────────────────────────────── */
const player = {
  group: playerBoat,
  turret: playerBoat.userData.turret,
  pos: new THREE.Vector3(0, 0, 0),
  vel: new THREE.Vector3(0, 0, 0),
  heading: 0,
  y: 0,
  vy: 0,
  pitch: 0, roll: 0,
  pitchV: 0, rollV: 0,
  speed: 0,
  throttle: 0,
  steer: 0,
  health: 100,
  maxHealth: 100,
  lastHit: -100,
  yawAim: 0,
  pitchAim: 0,
  fireCd: 0,
  recoil: 0,
  wakeTimer: 0,
  sprayTimer: 0,
  roosterTimer: 0,
  waveHitTimer: 7,
  impactCd: 0,
  lastBowWater: 0,
  direction: new THREE.Vector3(0, 0, 1),
  right: new THREE.Vector3(1, 0, 0)
};

const playerCraft = {
  length: 7.75,
  beam: 2.7,
  draftOffset: 0.13,
  spring: 19,
  damp: 6.2
};

function updatePlayer(dt, t, input) {
  const forward = new THREE.Vector3(Math.sin(player.heading), 0, Math.cos(player.heading));
  const right = new THREE.Vector3(Math.cos(player.heading), 0, -Math.sin(player.heading));
  player.direction.copy(forward);
  player.right.copy(right);

  const boost = input.boost && input.throttle > 0;
  const targetThrottle = input.throttle;
  player.throttle = damp(player.throttle, targetThrottle, 3.8, dt);
  const power = boost ? 19.5 : 15.5;
  let fSpeed = player.vel.dot(forward);
  let lSpeed = player.vel.dot(right);

  fSpeed += player.throttle * power * dt;
  const drag = 0.0145 * fSpeed * Math.abs(fSpeed) + 0.12 * fSpeed;
  fSpeed -= drag * dt;
  fSpeed = clamp(fSpeed, -7.0, boost ? 35.5 : 30.5);
  lSpeed *= Math.exp(-2.8 * dt);

  const steerAuthority = 0.22 + Math.min(Math.abs(fSpeed) / 26, 1.0) * 0.64;
  const turn = -input.steer * steerAuthority * (fSpeed < -0.5 ? -1 : 1);
  player.heading += turn * dt;
  player.steer = damp(player.steer, input.steer, 5.5, dt);
  player.speed = fSpeed;

  player.vel.copy(forward).multiplyScalar(fSpeed).addScaledVector(right, lSpeed);

  // wave slope pushes the hull downhill a little
  const n = waveNormal(player.pos.x, player.pos.z, t);
  player.vel.x += -n.x * 2.8 * dt;
  player.vel.z += -n.z * 2.8 * dt;
  player.pos.addScaledVector(player.vel, dt);

  // sample hull points
  const bowX = player.pos.x + forward.x * 3.28;
  const bowZ = player.pos.z + forward.z * 3.28;
  const sternX = player.pos.x - forward.x * 2.9;
  const sternZ = player.pos.z - forward.z * 2.9;
  const portX = player.pos.x - right.x * 1.32;
  const portZ = player.pos.z - right.z * 1.32;
  const stbdX = player.pos.x + right.x * 1.32;
  const stbdZ = player.pos.z + right.z * 1.32;
  const hBow = waveHeight(bowX, bowZ, t);
  const hStern = waveHeight(sternX, sternZ, t);
  const hPort = waveHeight(portX, portZ, t);
  const hStbd = waveHeight(stbdX, stbdZ, t);
  const hCenter = waveHeight(player.pos.x, player.pos.z, t);
  const avg = (hBow + hStern + hPort + hStbd + hCenter) * 0.2;

  const planing = Math.min(Math.max(fSpeed, 0) / 30, 1);
  const targetY = avg + playerCraft.draftOffset + planing * 0.12;
  const accel = (player.throttle * power);
  player.vy += (targetY - player.y) * playerCraft.spring * dt;
  player.vy -= player.vy * playerCraft.damp * dt;
  player.vy -= accel * 0.0009 * dt;
  player.y += player.vy * dt;

  const pitchTarget = -Math.atan2(hBow - hStern, 6.18) * 1.15 - planing * 0.075 + player.throttle * 0.005;
  const rollTarget = Math.atan2(hStbd - hPort, 2.64) * 1.05 - input.steer * Math.min(Math.abs(fSpeed) / 24, 1) * 0.14;
  player.pitchV += (pitchTarget - player.pitch) * 10.5 * dt - player.pitchV * 4.3 * dt;
  player.rollV += (rollTarget - player.roll) * 9.0 * dt - player.rollV * 4.1 * dt;
  player.pitch += player.pitchV * dt;
  player.roll += player.rollV * dt;

  player.group.position.set(player.pos.x, player.y, player.pos.z);
  player.group.rotation.order = 'YXZ';
  player.group.rotation.set(player.pitch, player.heading, player.roll);

  // wake and bow spray
  player.wakeTimer -= dt;
  if (fSpeed > 2 && player.wakeTimer <= 0) {
    player.wakeTimer = 0.045;
    const sternX = player.pos.x - forward.x * 3.15;
    const sternZ = player.pos.z - forward.z * 3.15;
    wakeTrail.add(sternX, sternZ, right.x, right.z, fSpeed, right);
    emitWake(sternX, sternZ, -forward.x * fSpeed * 0.19, -forward.z * fSpeed * 0.19, fSpeed);
  }

  // prop wash / rooster tail
  player.roosterTimer -= dt;
  if (fSpeed > 8 && player.roosterTimer <= 0) {
    player.roosterTimer = 0.045;
    for (const side of [-1, 1]) {
      const ex = player.pos.x - forward.x * 3.75 + right.x * side * 0.58;
      const ez = player.pos.z - forward.z * 3.75 + right.z * side * 0.58;
      for (let i = 0; i < 2; i++) {
        sprayPool.spawn({
          x: ex + rand(-0.28, 0.28), y: waveHeight(ex, ez, t) + rand(0.12, 0.4), z: ez + rand(-0.28, 0.28),
          vx: -forward.x * rand(2.0, 6.5) + right.x * side * rand(0.2, 1.8) + rand(-0.5, 0.5),
          vy: rand(1.6, 4.8),
          vz: -forward.z * rand(2.0, 6.5) + right.z * side * rand(0.2, 1.8) + rand(-0.5, 0.5),
          life: rand(0.5, 1.15), size: rand(0.38, 0.95), grow: rand(0.3, 0.8),
          gravity: -8.5, drag: 0.5, alpha: rand(0.24, 0.52), color: 0xe7f6f7
        });
      }
    }
  }

  player.sprayTimer -= dt;
  if (fSpeed > 6 && player.sprayTimer <= 0 && Math.random() < 0.55 + planing * 0.4) {
    player.sprayTimer = 0.13;
    const sx = player.pos.x + forward.x * 2.6 + right.x * rand(-1.2, 1.2);
    const sz = player.pos.z + forward.z * 2.6 + right.z * rand(-1.2, 1.2);
    const side = Math.random() < 0.5 ? -1 : 1;
    for (let i = 0; i < 8; i++) {
      sprayPool.spawn({
        x: sx, y: waveHeight(sx, sz, t) + 0.2, z: sz,
        vx: right.x * side * rand(0.8, 3.4) + forward.x * fSpeed * 0.035 + rand(-0.6, 0.6),
        vy: rand(1.6, 5.2), vz: right.z * side * rand(0.8, 3.4) + forward.z * fSpeed * 0.035 + rand(-0.6, 0.6),
        life: rand(0.65, 1.45), size: rand(0.38, 1.1), grow: 0.6,
        gravity: -9.8, drag: 0.65, alpha: rand(0.28, 0.66), color: 0xe4f7f6
      });
    }
    // low, wide sheets of mist peeling off the hull
    for (let i = 0; i < 2; i++) {
      sprayPool.spawn({
        x: sx + rand(-0.8, 0.8), y: waveHeight(sx, sz, t) + 0.1, z: sz + rand(-0.8, 0.8),
        vx: right.x * side * rand(0.8, 2.2), vy: rand(0.3, 1.2), vz: right.z * side * rand(0.8, 2.2),
        life: rand(0.8, 1.5), size: rand(1.4, 2.6), grow: 1.1,
        gravity: -1.0, drag: 1.0, alpha: rand(0.08, 0.18), color: 0xd9f3f5
      });
    }
  }

  // wave impacts
  player.impactCd -= dt;
  const bowWater = waveHeight(bowX, bowZ, t);
  const bowWaterV = waveVerticalSpeed(bowX, bowZ, t);
  const relativeImpact = Math.abs(bowWater - player.y) + Math.abs(bowWaterV) * 0.2;
  if (player.impactCd <= 0 && fSpeed > 2.5 && relativeImpact > 1.15) {
    player.impactCd = rand(0.9, 1.8);
    const count = Math.floor(rand(14, 26));
    emitSpray(bowX, bowWater + 0.15, bowZ, count, 2.8, 5.5, 0.7);
    emitSpray(player.pos.x - forward.x * 2.5, avg, player.pos.z - forward.z * 2.5, 8, 2.0, 3.0, 0.55);
    cameraShake = Math.max(cameraShake, 0.28);
    splashFlash(0.25);
    if (sound.ready) sound.splash(Math.min(1, relativeImpact * 0.4));
  }
  player.lastBowWater = bowWater;

  // occasional wave washing over the bow (more frequent in a rogue swell)
  player.waveHitTimer -= dt;
  if (player.waveHitTimer <= 0 && state.started && !state.gameOver) {
    const rogueHit = rogue.amp > 0.36;
    player.waveHitTimer = rogueHit ? rand(3.4, 6.2) : rand(7.5, 13.5);
    const strength = rogueHit ? rand(1.15, 1.7) : rand(0.7, 1.05);
    emitSpray(bowX, bowWater + 0.3, bowZ, Math.round(34 * strength), 4.6 * strength, 7.5 * strength, 0.88 * strength);
    const midX = player.pos.x + forward.x * 1.8;
    const midZ = player.pos.z + forward.z * 1.8;
    emitSpray(midX, avg + 0.28, midZ, Math.round(18 * strength), 3.2 * strength, 5.0 * strength, 0.6 * strength);
    player.vy += 1.1 * strength;
    player.pitchV += rand(-0.18, 0.18) * strength;
    player.rollV += rand(-0.24, 0.24) * strength;
    player.vel.x += n.x * -0.45 * strength;
    player.vel.z += n.z * -0.45 * strength;
    cameraShake = Math.max(cameraShake, 0.32 * strength);
    splashFlash(0.38 * strength);
    if (sound.ready) sound.splash(0.65 * strength);
  }

  // damage smoke and small deck fire
  if (state.started && !state.gameOver && player.health < 46 && Math.random() < dt * 8.5) {
    const dx = player.pos.x - forward.x * rand(0.2, 2.6) + right.x * rand(-0.8, 0.8);
    const dz = player.pos.z - forward.z * rand(0.2, 2.6) + right.z * rand(-0.8, 0.8);
    smokePool.spawn({
      x: dx, y: player.y + rand(0.8, 1.4), z: dz,
      vx: rand(-0.35, 0.35), vy: rand(0.35, 1.1), vz: rand(-0.35, 0.35),
      life: rand(1.1, 2.4), size: rand(0.45, 1.1), grow: rand(0.7, 1.5),
      gravity: 0.2, drag: 0.4, alpha: rand(0.08, 0.2), color: 0x111616
    });
    if (player.health < 22 && Math.random() < 0.45) {
      firePool.spawn({
        x: dx, y: player.y + 0.55, z: dz,
        vx: rand(-0.25, 0.25), vy: rand(0.4, 1.4), vz: rand(-0.25, 0.25),
        life: rand(0.2, 0.55), size: rand(0.2, 0.55), grow: 0.5,
        gravity: 1.0, drag: 0.9, alpha: rand(0.35, 0.75), color: 0xff6a2a
      });
    }
  }

  // health regeneration
  if (state.started && !state.gameOver && t - player.lastHit > 10) {
    player.health = Math.min(player.maxHealth, player.health + 0.95 * dt);
  }

  // recoil returns
  player.recoil = damp(player.recoil, 0, 14, dt);
  const pitchGroup = player.turret.userData.pitch;
  if (pitchGroup) pitchGroup.position.z = 0.36 + player.recoil * -0.18;

  // 3D aim marker
  const markerY = waveHeight(aimPoint.x, aimPoint.z, t) + 0.07;
  aimMarker.position.set(aimPoint.x, markerY, aimPoint.z);
  const d = camera.position.distanceTo(aimMarker.position);
  const ms = clamp(0.45 + d * 0.025, 0.55, 4.0);
  aimMarker.scale.setScalar(ms);
  aimMarker.visible = state.started && !state.gameOver;
}

/* ── enemies ──────────────────────────────────────────────────────────── */
const enemies = [];
const enemyHitboxes = [];
let enemyId = 0;

class Enemy {
  constructor(difficulty = 1) {
    this.id = ++enemyId;
    this.template = getEnemyTemplate();
    this.group = this.template.clone(true);
    this.group.scale.setScalar(0.94);
    this.turret = this.group.getObjectByName('turretYaw');
    this.turretPitch = this.group.getObjectByName('barrelPitch');
    this.muzzle = this.group.getObjectByName('muzzleTarget');
    this.health = 86 + difficulty * 12;
    this.maxHealth = this.health;
    this.speed = 12 + Math.min(difficulty * 0.65, 7);
    this.maxSpeed = 18 + Math.min(difficulty * 0.9, 9);
    this.heading = 0;
    this.pos = new THREE.Vector3();
    this.vel = new THREE.Vector3();
    this.y = 0; this.vy = 0;
    this.pitch = 0; this.roll = 0; this.pitchV = 0; this.rollV = 0;
    this.dead = false;
    this.deadTime = 0;
    this.fireTimer = rand(1.5, 3.4);
    this.strafeDir = Math.random() < 0.5 ? -1 : 1;
    this.strafeTimer = rand(4, 8);
    this.wakeTimer = 0;
    this.hitFlash = 0;
    this.turretYaw = 0;
    this.aimPitch = 0;

    const angle = rand(0, TAU);
    const dist = rand(150, 235);
    this.pos.set(player.pos.x + Math.sin(angle) * dist, 0, player.pos.z + Math.cos(angle) * dist);
    this.heading = Math.atan2(player.pos.x - this.pos.x, player.pos.z - this.pos.z);
    this.y = waveHeight(this.pos.x, this.pos.z, clockTime);

    const hitbox = new THREE.Mesh(new THREE.SphereGeometry(2.6, 8, 6), new THREE.MeshBasicMaterial({ visible: false, depthWrite: false }));
    hitbox.position.y = 0.6;
    hitbox.userData.enemy = this;
    this.group.add(hitbox);
    this.hitbox = hitbox;
    enemyHitboxes.push(hitbox);

    scene.add(this.group);
    enemies.push(this);
  }

  update(dt, t) {
    if (this.dead) {
      this.deadTime += dt;
      this.y = damp(this.y, waveHeight(this.pos.x, this.pos.z, t) - Math.min(this.deadTime * 0.5, 2.5), 1.4, dt);
      this.pitch = damp(this.pitch, 0.35, 0.6, dt);
      this.roll = damp(this.roll, Math.sin(this.deadTime * 2.2) * 0.15 + 0.65, 0.55, dt);
      this.group.position.set(this.pos.x, this.y, this.pos.z);
      this.group.rotation.set(this.pitch, this.heading, this.roll);
      if (this.deadTime < 2.6 && Math.random() < dt * 8) {
        emitSmoke(this.pos.x, this.y + 0.8, this.pos.z, 1, 1.5, 0x101414);
      }
      if (this.deadTime > 3.6) this.dispose();
      return;
    }

    const toPlayer = new THREE.Vector3().subVectors(player.pos, this.pos);
    const dist = toPlayer.length();
    const toAngle = Math.atan2(toPlayer.x, toPlayer.z);

    this.strafeTimer -= dt;
    if (this.strafeTimer <= 0) {
      this.strafeDir *= -1;
      this.strafeTimer = rand(3.5, 7.5);
    }

    let desired;
    if (dist > 78) {
      desired = toAngle;
    } else if (dist < 23) {
      desired = toAngle + Math.PI + this.strafeDir * 0.4;
    } else {
      const orbit = toAngle + this.strafeDir * Math.PI * 0.48;
      const blend = clamp((72 - dist) / 42, 0, 1);
      desired = toAngle + angleDelta(toAngle, orbit) * blend;
      // aim slightly ahead of the player at longer ranges
      if (dist > 42) {
        const lead = clamp(dist / 50, 0, 2.2);
        const pred = Math.atan2(player.pos.x + player.vel.x * lead - this.pos.x, player.pos.z + player.vel.z * lead - this.pos.z);
        desired = desired + angleDelta(desired, pred) * 0.4;
      }
    }

    // separation from other enemies
    for (const other of enemies) {
      if (other === this || other.dead) continue;
      const dx = this.pos.x - other.pos.x, dz = this.pos.z - other.pos.z;
      const d2 = dx * dx + dz * dz;
      if (d2 < 110 && d2 > 0.001) {
        const away = Math.atan2(dx, dz);
        desired += angleDelta(desired, away) * 0.2;
      }
    }

    const turnRate = 0.72 + Math.min(dist / 200, 0.35);
    this.heading = smoothAngle(this.heading, desired, turnRate, dt);

    let targetSpeed = this.maxSpeed;
    if (dist < 22) targetSpeed = this.maxSpeed * 0.7;
    if (dist < 95 && dist > 30) targetSpeed = this.maxSpeed * 0.83;
    this.speed = damp(this.speed, targetSpeed, 0.85, dt);

    const forward = new THREE.Vector3(Math.sin(this.heading), 0, Math.cos(this.heading));
    const right = new THREE.Vector3(Math.cos(this.heading), 0, -Math.sin(this.heading));
    this.pos.addScaledVector(forward, this.speed * dt);

    // island collision
    for (const isl of islands) {
      const dx = this.pos.x - isl.x, dz = this.pos.z - isl.z;
      const d = Math.hypot(dx, dz);
      if (d < isl.radius + 8) {
        const push = (isl.radius + 8 - d) / Math.max(d, 0.01);
        this.pos.x += dx * push;
        this.pos.z += dz * push;
        this.heading += angleDelta(this.heading, Math.atan2(dx, dz)) * 0.08;
      }
    }

    const bowX = this.pos.x + forward.x * 2.9;
    const bowZ = this.pos.z + forward.z * 2.9;
    const sternX = this.pos.x - forward.x * 2.7;
    const sternZ = this.pos.z - forward.z * 2.7;
    const portX = this.pos.x - right.x * 1.2;
    const portZ = this.pos.z - right.z * 1.2;
    const stbdX = this.pos.x + right.x * 1.2;
    const stbdZ = this.pos.z + right.z * 1.2;
    const hBow = waveHeight(bowX, bowZ, t);
    const hStern = waveHeight(sternX, sternZ, t);
    const hPort = waveHeight(portX, portZ, t);
    const hStbd = waveHeight(stbdX, stbdZ, t);
    const avg = (hBow + hStern + hPort + hStbd) * 0.25;
    const targetY = avg + 0.1;
    this.vy += (targetY - this.y) * 18 * dt;
    this.vy -= this.vy * 5.8 * dt;
    this.y += this.vy * dt;
    const pitchTarget = -Math.atan2(hBow - hStern, 5.6) * 1.25;
    const rollTarget = Math.atan2(hStbd - hPort, 2.4) * 1.05 - this.strafeDir * 0.035;
    this.pitchV += (pitchTarget - this.pitch) * 9.5 * dt - this.pitchV * 4.2 * dt;
    this.rollV += (rollTarget - this.roll) * 8.3 * dt - this.rollV * 4.0 * dt;
    this.pitch += this.pitchV * dt;
    this.roll += this.rollV * dt;

    this.group.position.set(this.pos.x, this.y, this.pos.z);
    this.group.rotation.order = 'YXZ';
    this.group.rotation.set(this.pitch, this.heading, this.roll);

    // wake
    this.wakeTimer -= dt;
    if (this.wakeTimer <= 0 && this.speed > 3) {
      this.wakeTimer = 0.06;
      emitWake(this.pos.x - forward.x * 2.7, this.pos.z - forward.z * 2.7, -forward.x * this.speed * 0.16, -forward.z * this.speed * 0.16, this.speed * 0.7);
    }

    // turret aim
    if (this.turret) {
      const targetLocal = this.group.worldToLocal(player.pos.clone().add(new THREE.Vector3(0, 1.0, 0)));
      const yawTarget = Math.atan2(targetLocal.x - this.turret.position.x, targetLocal.z - this.turret.position.z);
      this.turretYaw = smoothAngle(this.turretYaw, yawTarget, 4.0, dt);
      this.turret.rotation.y = this.turretYaw;
      if (this.turretPitch) {
        const horiz = Math.hypot(targetLocal.x, targetLocal.z);
        const pitch = -Math.atan2(targetLocal.y - 0.85, Math.max(horiz, 0.1));
        this.aimPitch = damp(this.aimPitch, clamp(pitch, -0.5, 0.08), 4.0, dt);
        this.turretPitch.rotation.x = this.aimPitch;
      }
    }

    // fire
    this.fireTimer -= dt;
    if (this.fireTimer <= 0 && dist < 118 && dist > 18) {
      this.fireTimer = rand(2.4, 4.2) - Math.min(state.wave * 0.08, 1.1);
      this.fire(dt);
    }

    this.hitFlash = Math.max(0, this.hitFlash - dt);
    const glow = this.group.getObjectByName('engineGlow');
    if (glow) glow.material.opacity = 0.35 + Math.sin(t * 17 + this.id) * 0.08;
  }

  fire() {
    if (!this.muzzle) return;
    const origin = new THREE.Vector3();
    this.muzzle.getWorldPosition(origin);
    const dist = player.pos.distanceTo(this.pos);
    const shellSpeed = 54 + Math.min(state.wave * 1.4, 12);
    const flight = dist / shellSpeed;
    const predicted = player.pos.clone().addScaledVector(player.vel, flight * 0.86);
    predicted.y = 0.9;
    const dir = predicted.sub(origin).normalize();
    const spread = (0.006 + dist / 16000) * 1.3;
    dir.x += rand(-spread, spread);
    dir.y += rand(-spread * 0.35, spread * 0.45);
    dir.z += rand(-spread, spread);
    dir.normalize();
    const vel = dir.multiplyScalar(shellSpeed);
    vel.y += 1.8;
    spawnShell(origin, vel, 'enemy');
    spawnFlash(origin.x, origin.y, origin.z, 2.2, 0xff6b33, 0.12, 0.8);
    if (sound.ready) sound.enemyFire(clamp(1 - dist / 180, 0.15, 0.8));
  }

  damage(amount, hitPoint) {
    if (this.dead) return;
    this.health -= amount;
    this.hitFlash = 0.14;
    spawnFlash(hitPoint.x, hitPoint.y, hitPoint.z, 2.6, 0xffd18a, 0.12, 0.9);
    emitFire(hitPoint.x, hitPoint.y, hitPoint.z, 12, 5, 0xffe9a5, 0xff5b24);
    if (this.health <= 0) {
      this.dead = true;
      this.deadTime = 0;
      this.health = 0;
      this.group.traverse(o => {
        if (o.isMesh && o.material) {
          o.material = matLib.gun;
        }
      });
      bigExplosion(this.pos.x, this.y + 0.4, this.pos.z, 1.05);
      if (sound.ready) sound.explosion(0.7);
      state.score += 100 + state.wave * 25;
      state.kills++;
      addBanner('TARGET DESTROYED', '+' + (100 + state.wave * 25) + ' PTS', 'MARAUDER SKIFF');
      cameraShake = Math.max(cameraShake, 0.32);
      const idx = enemyHitboxes.indexOf(this.hitbox);
      if (idx >= 0) enemyHitboxes.splice(idx, 1);
      this.hitbox.removeFromParent();
    }
  }

  dispose() {
    const idx = enemies.indexOf(this);
    if (idx >= 0) enemies.splice(idx, 1);
    scene.remove(this.group);
    const hb = enemyHitboxes.indexOf(this.hitbox);
    if (hb >= 0) enemyHitboxes.splice(hb, 1);
    if (this.hitbox) {
      this.hitbox.removeFromParent();
      this.hitbox.geometry.dispose();
      this.hitbox.material.dispose();
    }
  }
}

function spawnEnemy(difficulty = state.wave) {
  const e = new Enemy(difficulty);
  return e;
}

/* ── projectiles ──────────────────────────────────────────────────────── */
const bullets = [];
const bulletGeo = new THREE.SphereGeometry(0.09, 8, 6);
const playerBulletMat = new THREE.MeshBasicMaterial({ color: 0xffedb0, toneMapped: false });
const enemyBulletMat = new THREE.MeshBasicMaterial({ color: 0xff6c36, toneMapped: false });
const tracerGeo = new THREE.CylinderGeometry(0.035, 0.09, 3.6, 6);

function spawnPlayerBullet(origin, dir) {
  const g = new THREE.Group();
  const core = new THREE.Mesh(bulletGeo, playerBulletMat);
  g.add(core);
  const tracer = new THREE.Mesh(tracerGeo, new THREE.MeshBasicMaterial({ color: 0xffbc56, transparent: true, opacity: 0.78, blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false }));
  tracer.position.y = -1.55;
  g.add(tracer);
  const glow = new THREE.Sprite(new THREE.SpriteMaterial({ map: TEX.sun, color: 0xffc45d, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending, depthWrite: false, depthTest: true }));
  glow.scale.set(2.8, 2.8, 1);
  g.add(glow);
  g.position.copy(origin);
  g.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.clone().normalize());
  scene.add(g);
  const vel = dir.clone().normalize().multiplyScalar(265);
  bullets.push({ group: g, pos: origin.clone(), prev: origin.clone(), vel, life: 3.0, type: 'player', radius: 0.9 });
}

function spawnShell(origin, vel, type) {
  const g = new THREE.Group();
  const core = new THREE.Mesh(bulletGeo, type === 'enemy' ? enemyBulletMat : playerBulletMat);
  core.scale.setScalar(1.5);
  g.add(core);
  const tracer = new THREE.Mesh(tracerGeo, new THREE.MeshBasicMaterial({
    color: type === 'enemy' ? 0xff3b1f : 0xffbc56, transparent: true, opacity: 0.8,
    blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false
  }));
  tracer.position.y = -1.55;
  tracer.scale.set(1.2, 1.2, 1.2);
  g.add(tracer);
  const glow = new THREE.Sprite(new THREE.SpriteMaterial({
    map: TEX.sun, color: type === 'enemy' ? 0xff4a26 : 0xffc45d, transparent: true,
    opacity: 0.9, blending: THREE.AdditiveBlending, depthWrite: false, depthTest: true
  }));
  glow.scale.set(3.2, 3.2, 1);
  g.add(glow);
  g.position.copy(origin);
  g.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), vel.clone().normalize());
  scene.add(g);
  bullets.push({ group: g, pos: origin.clone(), prev: origin.clone(), vel: vel.clone(), life: 5.0, type, radius: 0.8 });
}

function updateBullets(dt, t) {
  for (let i = bullets.length - 1; i >= 0; i--) {
    const b = bullets[i];
    b.prev.copy(b.pos);
    if (b.type === 'enemy') {
      b.vel.y -= 3.6 * dt;
    }
    b.pos.addScaledVector(b.vel, dt);
    b.life -= dt;
    b.group.position.copy(b.pos);
    b.group.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), b.vel.clone().normalize());

    let hit = false;
    if (b.type === 'player') {
      for (const e of enemies) {
        if (e.dead) continue;
        const center = e.pos.clone(); center.y = e.y + 0.55;
        if (segmentSphere(b.prev, b.pos, center, 2.55)) {
          e.damage(36, b.pos.clone());
          if (sound.ready) sound.hit();
          hit = true;
          break;
        }
      }
    } else {
      const center = player.pos.clone(); center.y = player.y + 0.55;
      if (segmentSphere(b.prev, b.pos, center, 2.35)) {
        damagePlayer(rand(7.5, 12.5), b.pos.clone());
        hit = true;
      }
    }

    if (!hit) {
      const wh = waveHeight(b.pos.x, b.pos.z, t);
      if (b.pos.y < wh + 0.03) {
        const count = b.type === 'enemy' ? 18 : 8;
        emitSpray(b.pos.x, wh + 0.08, b.pos.z, count, 2.2, 4.2, 0.45);
        if (b.type === 'enemy') spawnFlash(b.pos.x, wh + 0.2, b.pos.z, 1.2, 0x9fe6ee, 0.18, 0.4);
        if (sound.ready) sound.splash(0.28);
        hit = true;
      }
    }

    if (hit || b.life <= 0 || b.pos.distanceTo(camera.position) > 900) {
      scene.remove(b.group);
      b.group.traverse(o => { if (o.material && o.material !== playerBulletMat && o.material !== enemyBulletMat) o.material.dispose(); });
      bullets.splice(i, 1);
    }
  }
}

function segmentSphere(a, b, center, radius) {
  const ab = new THREE.Vector3().subVectors(b, a);
  const ac = new THREE.Vector3().subVectors(center, a);
  const abLen2 = ab.lengthSq();
  if (abLen2 < 1e-8) return ac.lengthSq() <= radius * radius;
  const t = clamp(ac.dot(ab) / abLen2, 0, 1);
  const p = a.clone().addScaledVector(ab, t);
  return p.distanceToSquared(center) <= radius * radius;
}

function damagePlayer(amount, point = null) {
  if (state.gameOver) return;
  player.health -= amount;
  player.lastHit = clockTime;
  damageFlashTimer = Math.max(damageFlashTimer, 0.35);
  cameraShake = Math.max(cameraShake, 0.5);
  if (point) {
    emitFire(point.x, point.y, point.z, 10, 4, 0xffd27d, 0xff4820);
    emitSmoke(point.x, point.y, point.z, 4, 1.4, 0x181a18);
  }
  if (sound.ready) sound.impact();
  if (player.health <= 0) {
    player.health = 0;
    triggerGameOver();
  }
}

/* ── player firing / aiming ───────────────────────────────────────────── */
const raycaster = new THREE.Raycaster();
const pointerNDC = new THREE.Vector2(0, 0);
const aimPoint = new THREE.Vector3(0, 0, 80);
const aimPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -0.7);
let aimEnemy = null;
let isFiring = false;

function updateAim(dt, t) {
  raycaster.setFromCamera(pointerNDC, camera);
  let hitPoint = null;
  aimEnemy = null;

  const hits = raycaster.intersectObjects(enemyHitboxes, false);
  if (hits.length) {
    hitPoint = hits[0].point.clone();
    aimEnemy = hits[0].object.userData.enemy;
    if (aimEnemy && aimEnemy.dead) { aimEnemy = null; hitPoint = null; }
  }
  if (!hitPoint) {
    const p = new THREE.Vector3();
    if (raycaster.ray.intersectPlane(aimPlane, p)) {
      hitPoint = p;
    } else {
      hitPoint = raycaster.ray.at(160, new THREE.Vector3());
    }
  }
  aimPoint.lerp(hitPoint, 1 - Math.exp(-dt * 18));

  const local = player.group.worldToLocal(aimPoint.clone());
  const turretPos = player.turret.position;
  const dx = local.x - turretPos.x;
  const dz = local.z - turretPos.z;
  const targetYaw = Math.atan2(dx, dz);
  player.yawAim = smoothAngle(player.yawAim, targetYaw, 9.5, dt);
  player.turret.rotation.y = player.yawAim;

  const horiz = Math.hypot(dx, dz);
  const targetPitch = clamp(-Math.atan2(local.y - turretPos.y - 0.4, Math.max(horiz, 0.2)), -0.3, 0.45);
  player.pitchAim = damp(player.pitchAim, targetPitch, 8, dt);
  const pitchGroup = player.turret.userData.pitch;
  if (pitchGroup) pitchGroup.rotation.x = player.pitchAim;

  if (isFiring && state.started && !state.gameOver) {
    player.fireCd -= dt;
    if (player.fireCd <= 0) {
      player.fireCd = 0.16;
      firePlayer();
    }
  } else {
    player.fireCd = Math.max(0, player.fireCd - dt);
  }
}

function firePlayer() {
  const muzzle = player.turret.userData.muzzle;
  if (!muzzle) return;
  const origin = new THREE.Vector3();
  muzzle.getWorldPosition(origin);
  const dir = aimPoint.clone().sub(origin).normalize();
  const spread = 0.0025;
  dir.x += rand(-spread, spread);
  dir.y += rand(-spread * 0.4, spread * 0.4);
  dir.z += rand(-spread, spread);
  dir.normalize();
  spawnPlayerBullet(origin, dir);
  spawnFlash(origin.x, origin.y, origin.z, 3.6, 0xffc45d, 0.1, 0.95);
  emitFire(origin.x, origin.y, origin.z, 4, 3, 0xffeab2, 0xff7a31);
  player.recoil = 1;
  cameraShake = Math.max(cameraShake, 0.075);
  if (sound.ready) sound.fire();
}

/* ── game state / waves ───────────────────────────────────────────────── */
const state = {
  started: false,
  gameOver: false,
  paused: false,
  wave: 1,
  score: 0,
  kills: 0,
  spawnQueue: 0,
  spawnTimer: 0,
  nextWaveTimer: -1,
  gameTime: 0,
  overTimer: 0,
  bannerTimer: 0,
  rogueTimer: 0
};

let clockTime = 0;
let cameraShake = 0;
let damageFlashTimer = 0;
let waterFlashTimer = 0;
let bannerTimeout = null;

const input = {
  throttle: 0,
  steer: 0,
  boost: false,
  keyW: false, keyS: false, keyA: false, keyD: false
};

function startWave(num) {
  state.wave = num;
  const count = Math.min(2 + Math.floor(num * 0.9), 7);
  state.spawnQueue = count;
  state.spawnTimer = 0.6;
  state.nextWaveTimer = -1;
  addBanner('HOSTILE WAVE ' + String(num).padStart(2, '0'), count + ' CONTACTS', num > 1 ? 'MARAUDERS CLOSING' : 'THEY HAVE YOUR SCENT');
}

function updateGame(dt, t) {
  if (!state.started) {
    updateAttractCamera(dt, t);
    return;
  }

  if (state.gameOver) {
    state.overTimer += dt;
    if (state.overTimer > 2.1 && !gameOverShown) {
      gameOverShown = true;
      showGameOver();
    }
    player.speed = damp(player.speed, 0, 2, dt);
    player.throttle = damp(player.throttle, 0, 2, dt);
    player.pitch = damp(player.pitch, 0.34, 0.8, dt);
    player.roll = damp(player.roll, 0.62, 0.8, dt);
    player.y = damp(player.y, player.y - 0.055, 0.5, dt);
    player.group.position.set(player.pos.x, player.y, player.pos.z);
    player.group.rotation.set(player.pitch, player.heading, player.roll);
    return;
  }

  state.gameTime += dt;

  // rogue swell scheduler
  state.rogueTimer += dt;
  if (!rogue.active && state.rogueTimer > rogue.next) {
    rogue.active = true;
    rogue.target = rand(0.75, 1.35);
    rogue.dir.set(rand(-1, 1), rand(-1, 1)).normalize();
    rogue.offset = rand(-0.8, 0.8);
    state.rogueTimer = 0;
    rogue.next = rand(16, 27);
    rogue.warned = false;
  }
  if (rogue.active) {
    rogue.amp = damp(rogue.amp, rogue.target, 0.65, dt);
    const warningThreshold = 0.35;
    document.getElementById('rogueWarning').classList.toggle('show', rogue.amp > warningThreshold);
    if (rogue.amp > warningThreshold) rogue.warned = true;
    if (state.rogueTimer > 10.5) {
      rogue.active = false;
      rogue.target = 0;
      document.getElementById('rogueWarning').classList.remove('show');
    }
  } else {
    rogue.amp = damp(rogue.amp, 0, 0.5, dt);
    if (rogue.amp < 0.01) rogue.amp = 0;
    document.getElementById('rogueWarning').classList.remove('show');
  }
  waterUniforms.uRogueAmp.value = rogue.amp;

  // waves
  if (state.spawnQueue > 0) {
    state.spawnTimer -= dt;
    if (state.spawnTimer <= 0 && enemies.length < 8) {
      spawnEnemy(state.wave);
      state.spawnQueue--;
      state.spawnTimer = rand(0.9, 1.55);
    }
  } else if (enemies.length === 0) {
    if (state.nextWaveTimer < 0) {
      state.nextWaveTimer = 4.0;
      addBanner('SECTOR CLEAR', 'REGROUP', 'NEW CONTACTS INBOUND');
    } else {
      state.nextWaveTimer -= dt;
      if (state.nextWaveTimer <= 0) startWave(state.wave + 1);
    }
  }

  for (const e of enemies.slice()) e.update(dt, t);

  // player vs enemy collision
  for (const e of enemies) {
    if (e.dead) continue;
    const dx = player.pos.x - e.pos.x;
    const dz = player.pos.z - e.pos.z;
    const d = Math.hypot(dx, dz);
    if (d < 4.2) {
      const nx = dx / Math.max(d, 0.01), nz = dz / Math.max(d, 0.01);
      player.pos.x += nx * (4.2 - d) * 0.55;
      player.pos.z += nz * (4.2 - d) * 0.55;
      e.pos.x -= nx * (4.2 - d) * 0.55;
      e.pos.z -= nz * (4.2 - d) * 0.55;
      if (player.impactCd <= 0) {
        player.impactCd = 1.1;
        damagePlayer(rand(4, 8), new THREE.Vector3((player.pos.x + e.pos.x) / 2, player.y + 0.4, (player.pos.z + e.pos.z) / 2));
        if (sound.ready) sound.crunch();
      }
    }
  }

  // island collision for player
  for (const isl of islands) {
    const dx = player.pos.x - isl.x;
    const dz = player.pos.z - isl.z;
    const d = Math.hypot(dx, dz);
    if (d < isl.radius + 5) {
      const nx = dx / Math.max(d, 0.01), nz = dz / Math.max(d, 0.01);
      player.pos.x = isl.x + nx * (isl.radius + 5);
      player.pos.z = isl.z + nz * (isl.radius + 5);
      const fSpeed = player.vel.dot(player.direction);
      player.vel.multiplyScalar(-0.25);
      if (player.impactCd <= 0 && Math.abs(fSpeed) > 4) {
        player.impactCd = 1.2;
        damagePlayer(3.5, player.pos.clone());
        cameraShake = 0.6;
      }
    }
  }
}

function triggerGameOver() {
  if (state.gameOver) return;
  state.gameOver = true;
  state.overTimer = 0;
  gameOverShown = false;
  bigExplosion(player.pos.x, player.y + 0.5, player.pos.z, 1.7);
  emitFire(player.pos.x, player.y + 0.8, player.pos.z, 80, 13, 0xffe1a0, 0xff3f13);
  emitSmoke(player.pos.x, player.y + 1.0, player.pos.z, 35, 5, 0x0c0e0d);
  if (sound.ready) sound.explosion(1.2);
  cameraShake = 1.4;
  isFiring = false;
  document.getElementById('reticle').style.opacity = '0';
  setTimeout(() => { document.getElementById('reticle').style.opacity = ''; }, 2600);
}

let gameOverShown = false;

/* ── camera ───────────────────────────────────────────────────────────── */
let cameraDistance = 13.2;
function updateCamera(dt, t, dead = false) {
  const forward = new THREE.Vector3(Math.sin(player.heading), 0, Math.cos(player.heading));
  const right = new THREE.Vector3(Math.cos(player.heading), 0, -Math.sin(player.heading));
  const speedNorm = clamp(Math.abs(player.speed) / 31, 0, 1);
  const back = cameraDistance + speedNorm * 2.4;
  const desired = player.pos.clone()
    .addScaledVector(forward, -back)
    .addScaledVector(right, 1.55 + clamp(-player.roll * 1.8, -1.2, 1.2));
  desired.y = player.y + 4.8 + speedNorm * 0.9;
  const localWater = waveHeight(desired.x, desired.z, t);
  desired.y = Math.max(desired.y, localWater + 2.1);

  if (dead) {
    desired.y += 1.6;
    desired.addScaledVector(forward, -5);
  }

  const dampSpeed = dead ? 1.2 : 3.7;
  camera.position.lerp(desired, 1 - Math.exp(-dampSpeed * dt));

  const look = player.pos.clone().addScaledVector(forward, dead ? 0 : 9.5);
  look.y = player.y + (dead ? 0.2 : 1.5);
  const waterLook = waveHeight(look.x, look.z, t);
  look.y = Math.max(look.y, waterLook + 0.6);

  if (cameraShake > 0.001) {
    const s = cameraShake;
    camera.position.x += Math.sin(t * 91.3) * s * 0.35 + Math.sin(t * 34.7) * s * 0.22;
    camera.position.y += Math.sin(t * 77.1) * s * 0.28;
    camera.position.z += Math.cos(t * 83.7) * s * 0.34;
    look.x += Math.sin(t * 63.9) * s * 0.2;
    look.y += Math.sin(t * 57.1) * s * 0.15;
    cameraShake *= Math.exp(-5.4 * dt);
  }

  camera.lookAt(look);
  const targetFov = 53 + speedNorm * 8 + (dead ? 4 : 0);
  camera.fov = damp(camera.fov, targetFov, 2.0, dt);
  camera.updateProjectionMatrix();
}

function updateAttractCamera(dt, t) {
  const a = 2.9 + Math.sin(t * 0.055) * 0.16;
  const rad = 16.0;
  const desired = new THREE.Vector3(
    player.pos.x + Math.sin(a) * rad,
    player.y + 4.6 + Math.sin(t * 0.17) * 0.25,
    player.pos.z + Math.cos(a) * rad
  );
  const localWater = waveHeight(desired.x, desired.z, t);
  desired.y = Math.max(desired.y, localWater + 2.4);
  camera.position.lerp(desired, 1 - Math.exp(-1.0 * dt));
  const look = new THREE.Vector3(player.pos.x - 1.45, player.y + 2.45, player.pos.z - 0.6);
  camera.lookAt(look);
  camera.fov = damp(camera.fov, 52, 1.2, dt);
  camera.updateProjectionMatrix();
}

/* ── reflection ───────────────────────────────────────────────────────── */
const reflectionMatrix = new THREE.Matrix4();
const camWorldPos = new THREE.Vector3();
const reflectorWorldPos = new THREE.Vector3(0, 0, 0);
const normal = new THREE.Vector3(0, 1, 0);
const view = new THREE.Vector3();
const target = new THREE.Vector3();
const lookAtPosition = new THREE.Vector3();
const rotationMatrix = new THREE.Matrix4();

function updateReflectionCamera() {
  camWorldPos.setFromMatrixPosition(camera.matrixWorld);
  view.subVectors(reflectorWorldPos, camWorldPos);
  view.reflect(normal).negate();
  view.add(reflectorWorldPos);

  rotationMatrix.extractRotation(camera.matrixWorld);
  lookAtPosition.set(0, 0, -1).applyMatrix4(rotationMatrix).add(camWorldPos);
  target.subVectors(reflectorWorldPos, lookAtPosition);
  target.reflect(normal).negate();
  target.add(reflectorWorldPos);

  reflectionCamera.position.copy(view);
  reflectionCamera.up.set(0, 1, 0).applyMatrix4(rotationMatrix).reflect(normal);
  reflectionCamera.lookAt(target);
  reflectionCamera.far = camera.far;
  reflectionCamera.near = camera.near;
  reflectionCamera.aspect = camera.aspect;
  reflectionCamera.fov = camera.fov;
  reflectionCamera.updateProjectionMatrix();
  reflectionCamera.updateMatrixWorld();

  reflectionMatrix.set(
    0.5, 0, 0, 0.5,
    0, 0.5, 0, 0.5,
    0, 0, 0.5, 0.5,
    0, 0, 0, 1
  );
  reflectionMatrix.multiply(reflectionCamera.projectionMatrix);
  reflectionMatrix.multiply(reflectionCamera.matrixWorldInverse);
  waterUniforms.uTextureMatrix.value.copy(reflectionMatrix);
}

function renderReflection() {
  skyMaterial.uniforms.uOutput.value = 0;
  sunGlow.visible = false;
  sunCore.visible = false;
  ocean.visible = false;
  aimMarker.visible = false;
  const hider = [sprayPool.points, firePool.points, smokePool.points];
  hider.forEach(p => p.visible = false);
  wakeTrail.mesh.visible = false;
  for (const b of bullets) b.group.visible = false;

  renderer.clippingPlanes = [reflectionClip];
  renderer.setRenderTarget(reflectionRT);
  renderer.render(scene, reflectionCamera);
  renderer.setRenderTarget(null);
  renderer.clippingPlanes = [];

  skyMaterial.uniforms.uOutput.value = 1;
  sunGlow.visible = true;
  sunCore.visible = true;
  ocean.visible = true;
  wakeTrail.mesh.visible = true;
  hider.forEach(p => p.visible = true);
  for (const b of bullets) b.group.visible = true;
}

/* ── audio ────────────────────────────────────────────────────────────── */
class Soundscape {
  constructor() {
    this.ctx = null;
    this.ready = false;
    this.muted = false;
    this.master = null;
    this.engineGain = null;
    this.engineOsc = [];
    this.engineFilter = null;
    this.oceanGain = null;
    this.windGain = null;
  }
  init() {
    if (this.ctx) { if (this.ctx.state === 'suspended') this.ctx.resume(); return; }
    const AC = window.AudioContext || window.webkitAudioContext;
    if (!AC) return;
    const ctx = new AC();
    this.ctx = ctx;
    const master = ctx.createGain();
    master.gain.value = this.muted ? 0 : 0.72;
    const comp = ctx.createDynamicsCompressor();
    comp.threshold.value = -18;
    comp.knee.value = 18;
    comp.ratio.value = 4;
    comp.attack.value = 0.006;
    comp.release.value = 0.24;
    master.connect(comp);
    comp.connect(ctx.destination);
    this.master = master;

    // ocean noise
    const length = ctx.sampleRate * 5;
    const buf = ctx.createBuffer(2, length, ctx.sampleRate);
    for (let ch = 0; ch < 2; ch++) {
      const d = buf.getChannelData(ch);
      let last = 0;
      for (let i = 0; i < length; i++) {
        const white = Math.random() * 2 - 1;
        last = last * 0.985 + white * 0.015;
        d[i] = last * 3.2 + white * 0.08;
      }
    }
    const oceanSrc = ctx.createBufferSource();
    oceanSrc.buffer = buf; oceanSrc.loop = true;
    const oceanFilter = ctx.createBiquadFilter();
    oceanFilter.type = 'lowpass'; oceanFilter.frequency.value = 620; oceanFilter.Q.value = 0.5;
    const oceanGain = ctx.createGain();
    oceanGain.gain.value = 0.15;
    oceanSrc.connect(oceanFilter); oceanFilter.connect(oceanGain); oceanGain.connect(master);
    oceanSrc.start();
    this.oceanGain = oceanGain;

    const windSrc = ctx.createBufferSource();
    windSrc.buffer = buf; windSrc.loop = true;
    const windFilter = ctx.createBiquadFilter();
    windFilter.type = 'bandpass'; windFilter.frequency.value = 1300; windFilter.Q.value = 0.45;
    const windGain = ctx.createGain();
    windGain.gain.value = 0.045;
    windSrc.connect(windFilter); windFilter.connect(windGain); windGain.connect(master);
    windSrc.start();
    this.windGain = windGain;

    // engine hum
    const engineFilter = ctx.createBiquadFilter();
    engineFilter.type = 'lowpass'; engineFilter.frequency.value = 240; engineFilter.Q.value = 3.0;
    const engineGain = ctx.createGain();
    engineGain.gain.value = 0.0;
    engineFilter.connect(engineGain); engineGain.connect(master);
    const baseFreqs = [38, 57, 114];
    baseFreqs.forEach((f, i) => {
      const osc = ctx.createOscillator();
      osc.type = i === 2 ? 'triangle' : 'sawtooth';
      osc.frequency.value = f;
      const g = ctx.createGain();
      g.gain.value = i === 2 ? 0.12 : 0.18;
      osc.connect(g); g.connect(engineFilter);
      osc.start();
      this.engineOsc.push(osc);
    });
    this.engineFilter = engineFilter;
    this.engineGain = engineGain;
    this.ready = true;
    if (ctx.state === 'suspended') ctx.resume();
  }
  setEngine(speed, throttle, boost) {
    if (!this.ready) return;
    const t = this.ctx.currentTime;
    const n = clamp(Math.abs(speed) / 32, 0, 1);
    const target = state.started && !state.gameOver ? 0.04 + n * 0.15 + (boost ? 0.03 : 0) : 0;
    this.engineGain.gain.setTargetAtTime(target, t, 0.2);
    const f = 38 + n * 64 + (boost ? 12 : 0);
    this.engineOsc[0].frequency.setTargetAtTime(f, t, 0.3);
    this.engineOsc[1].frequency.setTargetAtTime(f * 1.5, t, 0.3);
    this.engineOsc[2].frequency.setTargetAtTime(f * 3.0, t, 0.3);
    this.engineFilter.frequency.setTargetAtTime(160 + n * 420 + throttle * 80, t, 0.25);
    if (this.oceanGain) this.oceanGain.gain.setTargetAtTime(0.11 + rogue.amp * 0.06 + n * 0.035, t, 1.0);
    if (this.windGain) this.windGain.gain.setTargetAtTime(0.04 + n * 0.11, t, 0.4);
  }
  noiseBurst(duration, volume, freq, q = 1) {
    if (!this.ready) return;
    const ctx = this.ctx;
    const len = Math.max(1, Math.floor(ctx.sampleRate * duration));
    const buf = ctx.createBuffer(1, len, ctx.sampleRate);
    const data = buf.getChannelData(0);
    for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1;
    const src = ctx.createBufferSource();
    src.buffer = buf;
    const filter = ctx.createBiquadFilter();
    filter.type = 'bandpass'; filter.frequency.value = freq; filter.Q.value = q;
    const g = ctx.createGain();
    const t = ctx.currentTime;
    g.gain.setValueAtTime(volume, t);
    g.gain.exponentialRampToValueAtTime(0.0001, t + duration);
    src.connect(filter); filter.connect(g); g.connect(this.master);
    src.start(t); src.stop(t + duration + 0.02);
  }
  tone(from, to, duration, volume, type = 'sine') {
    if (!this.ready) return;
    const ctx = this.ctx;
    const osc = ctx.createOscillator();
    const g = ctx.createGain();
    const t = ctx.currentTime;
    osc.type = type;
    osc.frequency.setValueAtTime(from, t);
    osc.frequency.exponentialRampToValueAtTime(Math.max(1, to), t + duration);
    g.gain.setValueAtTime(volume, t);
    g.gain.exponentialRampToValueAtTime(0.0001, t + duration);
    osc.connect(g); g.connect(this.master);
    osc.start(t); osc.stop(t + duration + 0.02);
  }
  fire() {
    this.noiseBurst(0.16, 0.34, 1500, 0.7);
    this.tone(170, 42, 0.2, 0.3, 'sine');
  }
  enemyFire(vol = 0.4) {
    this.noiseBurst(0.12, 0.13 * vol, 900, 0.8);
    this.tone(120, 40, 0.16, 0.1 * vol, 'sine');
  }
  hit() { this.noiseBurst(0.09, 0.16, 3200, 1.2); this.tone(260, 90, 0.08, 0.06, 'square'); }
  impact() {
    this.noiseBurst(0.35, 0.4, 240, 0.6);
    this.tone(92, 28, 0.42, 0.35, 'sine');
    this.noiseBurst(0.2, 0.18, 2200, 0.9);
  }
  explosion(size = 1) {
    this.noiseBurst(0.65 * size, 0.44 * size, 130, 0.5);
    this.tone(82, 22, 0.8 * size, 0.5 * size, 'sine');
    this.noiseBurst(0.3, 0.2 * size, 900, 0.5);
  }
  splash(vol = 0.4) { this.noiseBurst(0.5, 0.14 * vol, 750, 0.5); }
  crunch() {
    this.noiseBurst(0.18, 0.3, 350, 1.5);
    this.tone(65, 30, 0.2, 0.25, 'square');
  }
  toggleMute() {
    this.muted = !this.muted;
    if (this.master) this.master.gain.setTargetAtTime(this.muted ? 0 : 0.72, this.ctx.currentTime, 0.06);
    return this.muted;
  }
}
const sound = new Soundscape();

/* ── input / UI ───────────────────────────────────────────────────────── */
const reticle = document.getElementById('reticle');
const bannerEl = document.getElementById('banner');
const bannerKicker = document.getElementById('bannerKicker');
const bannerTitle = document.getElementById('bannerTitle');
const bannerSub = document.getElementById('bannerSub');
const audioBtn = document.getElementById('audioBtn');

function addBanner(kicker, title, sub) {
  bannerKicker.textContent = kicker;
  bannerTitle.textContent = title;
  bannerSub.textContent = sub;
  bannerEl.classList.remove('show');
  void bannerEl.offsetWidth;
  bannerEl.classList.add('show');
  clearTimeout(bannerTimeout);
  bannerTimeout = setTimeout(() => bannerEl.classList.remove('show'), 2400);
}

function splashFlash(amount = 0.3) {
  waterFlashTimer = Math.max(waterFlashTimer, amount);
}

function showGameOver() {
  document.getElementById('goScore').textContent = state.score;
  document.getElementById('goKills').textContent = state.kills;
  document.getElementById('goWave').textContent = state.wave;
  document.getElementById('gameOver').classList.add('show');
  document.getElementById('pauseOverlay').classList.remove('show');
  document.getElementById('app').classList.remove('playing');
}

function startGame() {
  sound.init();
  document.getElementById('loading').classList.add('done');
  document.getElementById('startScreen').style.display = 'none';
  document.getElementById('gameOver').classList.remove('show');
  document.getElementById('pauseOverlay').classList.remove('show');
  document.getElementById('app').classList.add('playing');
  document.getElementById('reticle').style.opacity = '';
  state.started = true;
  state.gameOver = false;
  state.paused = false;
  state.wave = 1;
  state.score = 0;
  state.kills = 0;
  state.gameTime = 0;
  state.spawnQueue = 0;
  state.nextWaveTimer = -1;
  state.rogueTimer = 0;
  rogue.active = false;
  rogue.amp = 0;
  rogue.next = rand(11, 16);
  waterUniforms.uRogueAmp.value = 0;
  player.health = 100;
  player.lastHit = -100;
  player.pos.set(0, 0, 0);
  player.heading = 0;
  player.vel.set(0, 0, 0);
  player.speed = 0;
  player.throttle = 0;
  player.y = waveHeight(0, 0, clockTime) + 0.13;
  player.vy = 0;
  player.pitch = 0; player.roll = 0; player.pitchV = 0; player.rollV = 0;
  player.waveHitTimer = rand(5, 8);
  player.roosterTimer = 0;
  player.group.position.set(0, player.y, 0);
  player.group.rotation.set(0, 0, 0);
  camera.position.set(-8, 6, -14);
  cameraShake = 0;
  isFiring = false;
  input.keyW = input.keyS = input.keyA = input.keyD = false;
  input.throttle = 0; input.steer = 0; input.boost = false;
  // clear previous
  for (const e of enemies.slice()) e.dispose();
  for (const b of bullets.slice()) {
    scene.remove(b.group);
    b.group.traverse(o => {
      if (o.material && o.material !== playerBulletMat && o.material !== enemyBulletMat) o.material.dispose();
    });
  }
  bullets.length = 0;
  enemyHitboxes.length = 0;
  sprayPool.clear(); firePool.clear(); smokePool.clear(); wakeTrail.clear();
  addBanner('DEPLOYMENT', 'WAVE 01', 'MARAUDERS INBOUND');
  startWave(1);
  document.getElementById('controlsHint').classList.remove('fade');
  setTimeout(() => document.getElementById('controlsHint').classList.add('fade'), 11000);
}

function restartGame() {
  document.getElementById('gameOver').classList.remove('show');
  startGame();
}

document.getElementById('startBtn').addEventListener('click', startGame);
document.getElementById('restartBtn').addEventListener('click', restartGame);

audioBtn.addEventListener('click', () => {
  sound.init();
  const muted = sound.toggleMute();
  audioBtn.textContent = muted ? 'SOUND OFF' : 'SOUND ON';
});

window.addEventListener('pointermove', e => {
  pointerNDC.x = (e.clientX / window.innerWidth) * 2 - 1;
  pointerNDC.y = -(e.clientY / window.innerHeight) * 2 + 1;
  reticle.style.left = e.clientX + 'px';
  reticle.style.top = e.clientY + 'px';
});
window.addEventListener('pointerdown', e => {
  if (e.target.closest('button, .screen')) return;
  if (e.button === 0 && state.started && !state.gameOver) {
    isFiring = true;
    sound.init();
  }
});
window.addEventListener('pointerup', e => {
  if (e.button === 0) isFiring = false;
});
window.addEventListener('blur', () => {
  isFiring = false;
  input.keyW = input.keyS = input.keyA = input.keyD = false;
  input.boost = false;
});
window.addEventListener('contextmenu', e => e.preventDefault());
window.addEventListener('wheel', e => {
  cameraDistance = clamp(cameraDistance + Math.sign(e.deltaY) * 0.9, 8.5, 19);
}, { passive: true });

window.addEventListener('keydown', e => {
  const k = e.key.toLowerCase();
  if (k === 'w' || e.key === 'ArrowUp') { input.keyW = true; e.preventDefault(); }
  if (k === 's' || e.key === 'ArrowDown') { input.keyS = true; e.preventDefault(); }
  if (k === 'a' || e.key === 'ArrowLeft') { input.keyA = true; e.preventDefault(); }
  if (k === 'd' || e.key === 'ArrowRight') { input.keyD = true; e.preventDefault(); }
  if (e.key === 'Shift') input.boost = true;
  if (k === 'm') {
    sound.init();
    const muted = sound.toggleMute();
    audioBtn.textContent = muted ? 'SOUND OFF' : 'SOUND ON';
  }
  if (k === 'r' && state.gameOver) restartGame();
  if (k === 'p' && state.started && !state.gameOver) {
    state.paused = !state.paused;
    document.getElementById('pauseOverlay').classList.toggle('show', state.paused);
    if (!state.paused) addBanner('RESUMED', 'RETURN TO WATCH', 'STAY SHARP');
  }
});
window.addEventListener('keyup', e => {
  const k = e.key.toLowerCase();
  if (k === 'w' || e.key === 'ArrowUp') input.keyW = false;
  if (k === 's' || e.key === 'ArrowDown') input.keyS = false;
  if (k === 'a' || e.key === 'ArrowLeft') input.keyA = false;
  if (k === 'd' || e.key === 'ArrowRight') input.keyD = false;
  if (e.key === 'Shift') input.boost = false;
});

/* ── HUD update ───────────────────────────────────────────────────────── */
let hudAccum = 0;
const radarCanvas = document.getElementById('radar');
const radarCtx = radarCanvas.getContext('2d');
const hud = {
  hullVal: document.getElementById('hullVal'),
  hullFill: document.getElementById('hullFill'),
  speedVal: document.getElementById('speedVal'),
  headingVal: document.getElementById('headingVal'),
  waveVal: document.getElementById('waveVal'),
  scoreVal: document.getElementById('scoreVal'),
  enemyVal: document.getElementById('enemyVal'),
  killVal: document.getElementById('killVal'),
  threatVal: document.getElementById('threatVal')
};

function drawRadar() {
  const ctx = radarCtx;
  const w = radarCanvas.width, h = radarCanvas.height;
  const cx = w / 2, cy = h / 2, R = 86;
  ctx.clearRect(0, 0, w, h);
  ctx.save();
  ctx.beginPath();
  ctx.arc(cx, cy, R, 0, TAU);
  ctx.clip();

  // grid
  ctx.strokeStyle = 'rgba(136,210,222,.13)';
  ctx.lineWidth = 1;
  for (const r of [R * 0.33, R * 0.66, R]) {
    ctx.beginPath(); ctx.arc(cx, cy, r, 0, TAU); ctx.stroke();
  }
  ctx.beginPath();
  ctx.moveTo(cx - R, cy); ctx.lineTo(cx + R, cy);
  ctx.moveTo(cx, cy - R); ctx.lineTo(cx, cy + R);
  ctx.stroke();

  // sweep
  const a = -Math.PI / 2 + (clockTime * 1.15) % TAU;
  const grad = ctx.createLinearGradient(cx, cy, cx + Math.cos(a) * R, cy + Math.sin(a) * R);
  grad.addColorStop(0, 'rgba(130,235,250,.34)');
  grad.addColorStop(1, 'rgba(130,235,250,0)');
  ctx.strokeStyle = grad;
  ctx.lineWidth = 1.5;
  ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + Math.cos(a) * R, cy + Math.sin(a) * R); ctx.stroke();

  const range = 250;
  const scale = R / range;
  const cosH = Math.cos(player.heading), sinH = Math.sin(player.heading);
  for (const e of enemies) {
    if (e.dead) continue;
    const dx = e.pos.x - player.pos.x;
    const dz = e.pos.z - player.pos.z;
    let rx = dx * cosH - dz * sinH;
    let rz = dx * sinH + dz * cosH;
    const dist = Math.hypot(rx, rz);
    if (dist > range) {
      rx = rx / dist * range * 0.94;
      rz = rz / dist * range * 0.94;
    }
    const px = cx + rx * scale;
    const py = cy - rz * scale;
    const near = dist < 55;
    ctx.shadowBlur = near ? 10 : 5;
    ctx.shadowColor = near ? 'rgba(255,88,62,.9)' : 'rgba(255,110,76,.6)';
    ctx.fillStyle = near ? '#ff5b42' : 'rgba(255,116,82,.86)';
    ctx.beginPath();
    ctx.arc(px, py, near ? 3.4 : 2.4, 0, TAU);
    ctx.fill();
    ctx.shadowBlur = 0;
    if (near) {
      ctx.strokeStyle = 'rgba(255,92,66,.34)';
      ctx.beginPath(); ctx.arc(px, py, 6.5, 0, TAU); ctx.stroke();
    }
  }

  // player
  ctx.shadowBlur = 9;
  ctx.shadowColor = 'rgba(150,241,255,.9)';
  ctx.fillStyle = '#a9f3ff';
  ctx.beginPath();
  ctx.moveTo(cx, cy - 7);
  ctx.lineTo(cx - 4.4, cy + 4.6);
  ctx.lineTo(cx, cy + 2.3);
  ctx.lineTo(cx + 4.4, cy + 4.6);
  ctx.closePath();
  ctx.fill();
  ctx.shadowBlur = 0;
  ctx.restore();

  // outer ring + cardinal ticks
  ctx.strokeStyle = 'rgba(142,229,243,.28)';
  ctx.lineWidth = 1;
  ctx.beginPath(); ctx.arc(cx, cy, R, 0, TAU); ctx.stroke();
  ctx.fillStyle = 'rgba(208,241,245,.45)';
  ctx.font = '9px "Barlow Condensed", sans-serif';
  ctx.textAlign = 'center';
  ctx.fillText('N', cx, cy - R + 10);
}

function updateHUD(dt) {
  hudAccum += dt;
  if (hudAccum < 0.055) return;
  hudAccum = 0;
  const hp = Math.max(0, player.health);
  hud.hullVal.textContent = Math.round(hp);
  hud.hullFill.style.transform = `scaleX(${clamp(hp / player.maxHealth, 0, 1)})`;
  hud.hullFill.style.background = hp > 55 ? 'linear-gradient(90deg,#2c8ca0,#9cf0ff)' : hp > 28 ? 'linear-gradient(90deg,#b07427,#ffcf7d)' : 'linear-gradient(90deg,#9e2317,#ff6b51)';
  const knots = Math.abs(player.speed) * 1.94384;
  hud.speedVal.textContent = knots.toFixed(1);
  let heading = (player.heading * 180 / Math.PI + 360) % 360;
  hud.headingVal.textContent = String(Math.round(heading)).padStart(3, '0');
  hud.waveVal.textContent = String(state.wave).padStart(2, '0');
  hud.scoreVal.textContent = state.score.toLocaleString('en-US');
  hud.enemyVal.textContent = enemies.filter(e => !e.dead).length;
  hud.killVal.textContent = state.kills;
  let threat = 'CLEAR';
  if (state.gameOver) threat = 'SIGNAL LOST';
  else if (enemies.some(e => !e.dead && e.pos.distanceTo(player.pos) < 45)) threat = 'CLOSE ACTION';
  else if (enemies.some(e => !e.dead)) threat = 'CONTACTS TRACKING';
  else if (state.spawnQueue > 0) threat = 'INBOUND';
  hud.threatVal.textContent = threat;

  drawRadar();
  reticle.classList.toggle('target', !!aimEnemy && !aimEnemy.dead);

  if (damageFlashTimer > 0) {
    damageFlashTimer -= 0.08;
    document.getElementById('damageFlash').style.opacity = clamp(damageFlashTimer * 2.4, 0, 0.95);
  } else {
    document.getElementById('damageFlash').style.opacity = '0';
  }
  if (waterFlashTimer > 0) {
    waterFlashTimer -= 0.08;
    document.getElementById('waterFlash').style.opacity = clamp(waterFlashTimer * 1.6, 0, 0.8);
  } else {
    document.getElementById('waterFlash').style.opacity = '0';
  }
}

/* ── animation loop ───────────────────────────────────────────────────── */
let last = performance.now();
let frameCount = 0;

function animate(now) {
  requestAnimationFrame(animate);
  const rawDt = (now - last) / 1000;
  last = now;
  const dt = Math.min(rawDt, 0.05);
  clockTime += dt;
  const t = clockTime;

  if (state.paused) {
    waterUniforms.uTime.value = t;
    skyMaterial.uniforms.uTime.value = t;
    renderer.render(scene, camera);
    return;
  }

  input.throttle = (input.keyW ? 1 : 0) + (input.keyS ? -1 : 0);
  input.steer = (input.keyD ? 1 : 0) + (input.keyA ? -1 : 0);

  if (state.started) {
    // correct gameTime double increment from updateGame
    updatePlayer(dt, t, input);
    updateAim(dt, t);
    updateGame(dt, t);
    updateBullets(dt, t);
    updateCamera(dt, t, state.gameOver);
    updateHUD(dt);
    sound.setEngine(player.speed, player.throttle, input.boost);
  } else {
    // attract-mode buoyancy so the boat still breathes with the swell
    const hc = waveHeight(player.pos.x, player.pos.z, t);
    player.y = lerp(player.y, hc + 0.13, 1 - Math.exp(-2.2 * dt));
    const fwd = new THREE.Vector3(Math.sin(player.heading), 0, Math.cos(player.heading));
    const rgt = new THREE.Vector3(Math.cos(player.heading), 0, -Math.sin(player.heading));
    const hb = waveHeight(player.pos.x + fwd.x * 3.2, player.pos.z + fwd.z * 3.2, t);
    const hs = waveHeight(player.pos.x - fwd.x * 3.2, player.pos.z - fwd.z * 3.2, t);
    const hp = waveHeight(player.pos.x - rgt.x * 1.3, player.pos.z - rgt.z * 1.3, t);
    const hst = waveHeight(player.pos.x + rgt.x * 1.3, player.pos.z + rgt.z * 1.3, t);
    player.pitch = damp(player.pitch, -Math.atan2(hb - hs, 6.4) * 1.1, 2.5, dt);
    player.roll = damp(player.roll, Math.atan2(hst - hp, 2.6), 2.4, dt);
    player.group.position.set(player.pos.x, player.y, player.pos.z);
    player.group.rotation.order = 'YXZ';
    player.group.rotation.set(player.pitch, player.heading, player.roll);
    updateAttractCamera(dt, t);
    updateHUD(dt);
  }

  sprayPool.update(dt, t);
  firePool.update(dt, t);
  smokePool.update(dt, t);
  wakeTrail.update(dt, t);
  updateFX(dt);

  // sky / clouds / sun
  skyMaterial.uniforms.uTime.value = t;
  skyMaterial.uniforms.uCloudOffset.value = (t * 0.00075) % 1;
  ocean.position.set(player.pos.x, 0, player.pos.z);
  skyMesh.position.copy(camera.position);
  sunGlow.position.copy(camera.position).addScaledVector(sunDir, 4000);
  sunCore.position.copy(camera.position).addScaledVector(sunDir, 3990);
  waterUniforms.uTime.value = t;
  waterUniforms.uCamPos.value.copy(camera.position);
  waterUniforms.uBoatPos.value.set(player.pos.x, player.y, player.pos.z);
  waterUniforms.uBoatSpeed.value = Math.abs(player.speed);
  waterUniforms.uBoatShadow.value = state.started ? 1 : 1;
  skyMaterial.uniforms.uOutput.value = 1;

  // light + shadow follow
  sunLight.target.position.set(player.pos.x, 0, player.pos.z);
  sunLight.position.set(player.pos.x, 0, player.pos.z).addScaledVector(sunDir, 160);
  sunLight.target.updateMatrixWorld();
  sunLight.updateMatrixWorld();

  // animation details
  const radar = playerBoat.getObjectByName('radarSpin');
  if (radar) radar.rotation.y = t * 2.2;
  const flag = playerBoat.getObjectByName('flag');
  if (flag) {
    const p = flag.geometry.attributes.position;
    const base = flag.userData.base;
    for (let i = 0; i < p.count; i++) {
      const bx = base[i * 3], by = base[i * 3 + 1], bz = base[i * 3 + 2];
      const u = clamp(-bx / 1.55, 0, 1);
      p.setX(i, bx + Math.sin(t * 5.2 + u * 5.4 + by * 2.0) * 0.11 * u);
      p.setY(i, by + Math.sin(t * 4.1 + u * 3.2) * 0.045 * u);
      p.setZ(i, bz + Math.sin(t * 6.3 + u * 6.1) * 0.09 * u);
    }
    p.needsUpdate = true;
  }

  // particle point scale
  const pointScale = (renderer.domElement.height / (2 * Math.tan(camera.fov * Math.PI / 360))) / renderer.getPixelRatio();
  [sprayPool, firePool, smokePool].forEach(pool => {
    pool.material.uniforms.uScale.value = pointScale * renderer.getPixelRatio();
    pool.material.uniforms.uExposure.value = renderer.toneMappingExposure;
  });

  // reflection uses final camera
  camera.updateMatrixWorld();
  updateReflectionCamera();
  renderReflection();
  renderer.render(scene, camera);

  if (!state.started) {
    // keep reticle hidden on attract
    reticle.style.opacity = '0';
  } else if (!state.gameOver) {
    reticle.style.opacity = '';
  }

  frameCount++;
  if (frameCount === 3) {
    document.getElementById('loading').classList.add('done');
    window.__appReady = true;
  }
}

/* ── resize ───────────────────────────────────────────────────────────── */
function onResize() {
  const w = window.innerWidth, h = window.innerHeight;
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5));
  renderer.setSize(w, h);
  camera.aspect = w / h;
  camera.updateProjectionMatrix();
  const size = new THREE.Vector2();
  renderer.getDrawingBufferSize(size);
  waterUniforms.uResolution.value.copy(size);
  reflectionRT.setSize(Math.max(2, Math.floor(size.x * 0.55)), Math.max(2, Math.floor(size.y * 0.55)));
}
window.addEventListener('resize', onResize);
onResize();

/* ── boot ─────────────────────────────────────────────────────────────── */
player.pos.set(0, 0, 0);
player.y = waveHeight(0, 0, clockTime) + 0.13;
player.group.position.set(0, player.y, 0);
camera.position.set(14.6, 6.2, -13.0);
updateReflectionCamera();
renderer.render(scene, camera);
requestAnimationFrame(animate);

// debug hook used for automated visual checks
window.__NEREID = {
  state, player, enemies, rogue,
  start: startGame,
  restart: restartGame,
  gameOver: triggerGameOver,
  spawnEnemy: () => spawnEnemy(state.wave),
  fire: () => firePlayer(),
  setCamera: (x, y, z) => camera.position.set(x, y, z),
  camera, scene, renderer, ocean, waterUniforms, skyMaterial, TEX, islands,
  pools: { sprayPool, firePool, smokePool }, bullets, sound
};
</script>
</body>
</html>

Watch the test

Video tests of this model on coding and agent work.

DeepSeek V4.1 Flash First Test | Better V4 Flash? | Coding with OpenCode, Frontend & Agentic Work11 Sep 2026

Same prompt, other models

7 more
Compare side by side

One email when a new model lands on the board or in the showcase. Unsubscribe anytime.