{"asset_map": {"music_battle": {"url": "https://d2oir5eh8rty2e.cloudfront.net/assets/sounds/music/direct_asset_generation_01M5NGVB586MR0FY9ZEH42TZXC_8107bdbb-7c83-4ea1-9ce4-a413d2040574_16015208-9cb8-4198-9b03-a74e78d741eb.mp3", "type": "audio", "prompt": "Upbeat energetic military march for a mobile arcade army runner game, snare drum rolls, brass hits, driving tempo, fun not grim, instrumental", "description": "Upbeat energetic military march for a mobile arcade army runner game, snare drum rolls, brass hits, driving tempo, fun not grim, instrumental", "subtype": "music", "aspect_ratio": null, "compression_suggestion": "original", "duration_secs": 30.0, "loop": true, "opus_url": "https://d2oir5eh8rty2e.cloudfront.net/assets/sounds/music/direct_asset_generation_01M5NGVB586MR0FY9ZEH42TZXC_8107bdbb-7c83-4ea1-9ce4-a413d2040574_16015208-9cb8-4198-9b03-a74e78d741eb.ogg", "animation": null}, "sfx_gunfire": {"url": "https://d2oir5eh8rty2e.cloudfront.net/assets/sounds/effect/direct_asset_generation_01M5NGVCD8X4KZATKAAZNKPK90_e54faf02-1dea-4cce-b26b-d731fc9f25af_106be76d-48a3-420d-a215-96068546ec1b.mp3", "type": "audio", "prompt": "Short cartoony arcade battle burst, rapid toy rifle fire mixed with a tank cannon thump, punchy mobile game style", "description": "Short cartoony arcade battle burst, rapid toy rifle fire mixed with a tank cannon thump, punchy mobile game style", "subtype": "effect", "aspect_ratio": null, "compression_suggestion": "original", "duration_secs": 1.84, "loop": false, "opus_url": "https://d2oir5eh8rty2e.cloudfront.net/assets/sounds/effect/direct_asset_generation_01M5NGVCD8X4KZATKAAZNKPK90_e54faf02-1dea-4cce-b26b-d731fc9f25af_106be76d-48a3-420d-a215-96068546ec1b.ogg", "animation": null}, "sfx_explosion": {"url": "https://d2oir5eh8rty2e.cloudfront.net/assets/sounds/effect/direct_asset_generation_01M5NGVD5V3W4FPZ00ZE57R022_6c9b8c2f-b9d7-4c46-9ef6-4d77bc83bd11_88a44950-449c-46d2-a53d-ebe2b655e28e.mp3", "type": "audio", "prompt": "Big satisfying cartoon explosion boom with debris, mobile arcade game", "description": "Big satisfying cartoon explosion boom with debris, mobile arcade game", "subtype": "effect", "aspect_ratio": null, "compression_suggestion": "original", "duration_secs": 1.77, "loop": false, "opus_url": "https://d2oir5eh8rty2e.cloudfront.net/assets/sounds/effect/direct_asset_generation_01M5NGVD5V3W4FPZ00ZE57R022_6c9b8c2f-b9d7-4c46-9ef6-4d77bc83bd11_88a44950-449c-46d2-a53d-ebe2b655e28e.ogg", "animation": null}, "sfx_gate": {"url": "https://d2oir5eh8rty2e.cloudfront.net/assets/sounds/effect/direct_asset_generation_01M5NGVE069F6F6704MG6DJGRR_a5b6a96b-301e-4c83-b484-7f8dc790196a_e11a375a-6214-42c5-822e-18912278f6bc.mp3", "type": "audio", "prompt": "Bright rewarding power-up whoosh with military radio chirp, reinforcements arriving, mobile game pickup sound", "description": "Bright rewarding power-up whoosh with military radio chirp, reinforcements arriving, mobile game pickup sound", "subtype": "effect", "aspect_ratio": null, "compression_suggestion": "original", "duration_secs": 1.3, "loop": false, "opus_url": "https://d2oir5eh8rty2e.cloudfront.net/assets/sounds/effect/direct_asset_generation_01M5NGVE069F6F6704MG6DJGRR_a5b6a96b-301e-4c83-b484-7f8dc790196a_e11a375a-6214-42c5-822e-18912278f6bc.ogg", "animation": null}, "sfx_victory": {"url": "https://d2oir5eh8rty2e.cloudfront.net/assets/sounds/effect/direct_asset_generation_01M5NGVEW1H2637X3PD6CKR0KR_dcb143b3-5c73-41b6-ad91-6e70a22d393d_51018abb-19bb-4a1c-9e9c-54ecb14edd11.mp3", "type": "audio", "prompt": "Short triumphant military victory fanfare, brass and snare, mobile game level complete", "description": "Short triumphant military victory fanfare, brass and snare, mobile game level complete", "subtype": "effect", "aspect_ratio": null, "compression_suggestion": "original", "duration_secs": 2.89, "loop": false, "opus_url": "https://d2oir5eh8rty2e.cloudfront.net/assets/sounds/effect/direct_asset_generation_01M5NGVEW1H2637X3PD6CKR0KR_dcb143b3-5c73-41b6-ad91-6e70a22d393d_51018abb-19bb-4a1c-9e9c-54ecb14edd11.ogg", "animation": null}}, "game_code": "<!DOCTYPE html>\n\n<html>\n<head>\n<meta charset=\"utf-8\"/>\n<meta content=\"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no\" name=\"viewport\"/>\n<title>10,000 Army Runner</title>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js\"></script>\n<style>\n  * { box-sizing: border-box; -webkit-user-select: none; user-select: none; -webkit-tap-highlight-color: transparent; }\n  html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #8fc9f2; touch-action: none; }\n  #c { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; }\n  #hud { position: absolute; inset: 0; pointer-events: none; font-family: 'Arial Black', 'Arial Rounded MT Bold', Impact, system-ui, sans-serif; color: #fff; overflow: hidden; }\n  .stroke { text-shadow: 0 3px 0 rgba(0,0,0,.35), -2px -2px 0 #0b1e45, 2px -2px 0 #0b1e45, -2px 2px 0 #0b1e45, 2px 2px 0 #0b1e45; }\n  svg { display: inline-block; vertical-align: middle; }\n  .topbar { position: absolute; top: 20px; left: 20px; right: 20px; display: flex; align-items: center; gap: 14px; }\n  .lvl { background: #16264a; border: 4px solid #fff; border-radius: 18px; padding: 10px 18px; font-size: 28px; white-space: nowrap; box-shadow: 0 6px 0 rgba(0,0,0,.25); }\n  .prog { position: relative; flex: 1; height: 26px; background: rgba(10,25,55,.55); border: 4px solid #fff; border-radius: 16px; box-shadow: 0 6px 0 rgba(0,0,0,.2); }\n  .prog-fill { position: absolute; left: 0; top: 0; bottom: 0; width: 0%; background: linear-gradient(90deg,#3b8cff,#7cc0ff); border-radius: 12px; }\n  .marker { position: absolute; top: 50%; width: 40px; height: 40px; margin: -20px 0 0 -20px; border-radius: 50%; background: #e0393a; border: 4px solid #fff; display: flex; align-items: center; justify-content: center; font-size: 20px; box-shadow: 0 3px 0 rgba(0,0,0,.3); }\n  .marker.fort { width: 52px; height: 52px; margin: -26px 0 0 -26px; font-size: 26px; }\n  .btn-round { pointer-events: auto; width: 96px; height: 96px; border-radius: 50%; border: 4px solid #fff; background: #16264a; color: #fff; font-size: 40px; display: flex; align-items: center; justify-content: center; box-shadow: 0 6px 0 rgba(0,0,0,.25); cursor: pointer; flex: 0 0 auto; }\n  .legend { position: absolute; top: 140px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; gap: 8px; background: rgba(10,25,55,.5); border: 3px solid rgba(255,255,255,.85); border-radius: 18px; padding: 8px 16px; font-size: 22px; white-space: nowrap; }\n  .legend .ic { width: 44px; height: 44px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 32px; border: 3px solid #fff; }\n  .armybar { display: none !important; position: absolute; bottom: 30px; left: 50%; transform: translateX(-50%); display: flex; flex-wrap: wrap-reverse; justify-content: center; gap: 10px; width: 690px; }\n  .chip { display: flex; align-items: center; gap: 8px; background: #fff; color: #16264a; border-radius: 20px; padding: 6px 14px 6px 6px; font-size: 30px; box-shadow: 0 5px 0 rgba(0,0,0,.25); min-width: 124px; transition: transform .12s; }\n  .chip .ic { width: 50px; height: 50px; border-radius: 14px; display: flex; align-items: center; justify-content: center; font-size: 36px; }\n  .chip.pop { transform: scale(1.18); }\n  .tag { position: absolute; left: 0; top: 0; will-change: transform; display: flex; flex-direction: column; align-items: center; gap: 4px; }\n  .tag .row { display: flex; align-items: center; gap: 8px; border: 4px solid #fff; border-radius: 18px; padding: 5px 12px; font-size: 26px; white-space: nowrap; box-shadow: 0 5px 0 rgba(0,0,0,.25); }\n  .tag.blue .row { background: #2f80ff; font-size: 32px; }\n  .tag.red .row { background: #e0393a; }\n  .tag.weak .row { background: #1f9d48; }\n  .tag .u { display: flex; align-items: center; gap: 4px; }\n  .tag .u svg { width: 30px; height: 30px; }\n  .tag .u.hit { background: #1f9d48; border-radius: 10px; padding: 0 6px; box-shadow: 0 0 0 3px #fff; }\n  .tag .tier { background: #ffd23f; color: #3a2400; border: 4px solid #fff; border-radius: 12px; padding: 2px 14px; font-size: 26px; box-shadow: 0 4px 0 rgba(0,0,0,.25); }\n  .tag .hint2 { font-size: 18px; color: #c9ffd6; }\n  .toast { position: absolute; top: 300px; left: 0; right: 0; text-align: center; font-size: 52px; line-height: 1.1; opacity: 0; transform: scale(.6); transition: opacity .2s, transform .25s cubic-bezier(.2,1.6,.4,1); padding: 0 30px; }\n  .toast.show { opacity: 1; transform: scale(1); }\n  .toast small { display: block; font-size: 30px; margin-top: 8px; color: #ffe27a; }\n  .float { position: absolute; left: 0; top: 0; font-size: 46px; white-space: nowrap; pointer-events: none; display: flex; align-items: center; gap: 6px; }\n  .float svg { width: 44px; height: 44px; }\n  .hint { position: absolute; bottom: 150px; left: 0; right: 0; text-align: center; font-size: 34px; opacity: 0; transition: opacity .4s; }\n  .hint.show { opacity: 1; animation: sway 1.2s ease-in-out infinite; }\n  @keyframes sway { 0%,100% { transform: translateX(-26px); } 50% { transform: translateX(26px); } }\n  .overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 26px; background: radial-gradient(ellipse at center, rgba(12,30,70,.45), rgba(5,12,30,.78)); pointer-events: auto; transition: opacity .3s; padding: 40px; }\n  .overlay.hidden { opacity: 0; visibility: hidden; pointer-events: none; }\n  .overlay.hidden * { pointer-events: none !important; }\n  .title { font-size: 112px; line-height: .9; text-align: center; letter-spacing: 2px; }\n  .title .b { color: #4b9bff; } .title .r { color: #ff4d4f; }\n  .title span { text-shadow: 0 8px 0 #0b1e45, -4px -4px 0 #fff, 4px -4px 0 #fff, -4px 4px 0 #fff, 4px 4px 0 #fff, 0 14px 24px rgba(0,0,0,.45); }\n  .sub { font-size: 34px; color: #ffd23f; }\n  .card { background: #fff; color: #16264a; border-radius: 28px; padding: 18px 26px; display: flex; flex-direction: column; gap: 8px; box-shadow: 0 10px 0 rgba(0,0,0,.25); min-width: 560px; }\n  .card h3 { margin: 0 0 4px; font-size: 30px; text-align: center; }\n  .rule { display: flex; align-items: center; gap: 10px; font-size: 22px; }\n  .rule .ic { width: 44px; height: 44px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 32px; flex: 0 0 auto; }\n  .rule .rn { width: 230px; }\n  .rule .beats { color: #22a046; font-size: 20px; }\n  .rule .bi { display: flex; gap: 6px; margin-left: auto; }\n  .rule .bi .ic { width: 38px; height: 38px; font-size: 26px; }\n  .btn { pointer-events: auto; font-family: inherit; font-size: 52px; padding: 26px 90px; border-radius: 30px; border: 5px solid #fff; background: linear-gradient(#ffdb4d,#ffad0a); color: #4a2a00; box-shadow: 0 10px 0 #a86a00, 0 16px 26px rgba(0,0,0,.35); cursor: pointer; }\n  .btn:active { transform: translateY(6px); box-shadow: 0 4px 0 #a86a00; }\n  .big { font-size: 96px; }\n  .surv { display: flex; gap: 14px; }\n  .note { font-size: 26px; text-align: center; opacity: .9; max-width: 600px; }\n  /* ===== welcome screen ===== */\n  .menu { position: absolute; inset: 0; pointer-events: auto; transition: opacity .45s, transform .45s; }\n  .menu.hidden { opacity: 0; visibility: hidden; pointer-events: none; transform: scale(1.04); }\n  .menu.hidden * { pointer-events: none !important; }\n  .menu-shade { position: absolute; left: 0; right: 0; pointer-events: none; }\n  .menu-shade.top { top: 0; height: 560px; background: linear-gradient(rgba(6,16,40,.82), rgba(6,16,40,.45) 55%, rgba(6,16,40,0)); }\n  .menu-shade.bottom { bottom: 0; height: 520px; background: linear-gradient(rgba(6,16,40,0), rgba(6,16,40,.55) 45%, rgba(6,16,40,.9)); }\n  .logo { position: absolute; top: 96px; left: 0; right: 0; display: flex; flex-direction: column; align-items: center; animation: logoIn .9s cubic-bezier(.2,1.5,.4,1) both; }\n  @keyframes logoIn { from { transform: translateY(-260px) scale(.6); opacity: 0; } to { transform: none; opacity: 1; } }\n  .logo-word { transform-origin: 50% 50%; position: relative; font-family: 'Arial Black', 'Arial Rounded MT Bold', Impact, system-ui, sans-serif; font-weight: 900; line-height: .9; white-space: nowrap; }\n  .lw-back { display: block; paint-order: stroke fill; }\n  .lw-front { position: absolute; left: 0; top: 0; display: block; -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; color: transparent; }\n  .logo-word.gold { font-size: 176px; letter-spacing: 2px; }\n  .logo-word.gold .lw-back { color: #1b1440; -webkit-text-stroke: 24px #1b1440;\n    text-shadow: 0 6px 0 #1b1440, 0 12px 0 #1b1440, 0 18px 0 #120d2e, 0 30px 34px rgba(0,0,0,.55); }\n  .logo-word.gold .lw-front { background-image: linear-gradient(180deg, #fffbd0 0%, #ffe45a 30%, #ffb800 58%, #ff8a00 80%, #e05e00 100%); -webkit-text-stroke: 5px #fff6c8; }\n  .logo-word.white { font-size: 88px; letter-spacing: 1px; margin-top: 4px; }\n  .logo-word.white .lw-back { color: #1d4fd6; -webkit-text-stroke: 20px #1b1440;\n    text-shadow: 0 5px 0 #1b1440, 0 10px 0 #120d2e, 0 22px 26px rgba(0,0,0,.5); }\n  .logo-word.white .lw-front { background-image: linear-gradient(180deg, #ffffff 0%, #ffffff 55%, #d6e6ff 75%, #a9c8ff 100%); -webkit-text-stroke: 6px #1d63f0; }\n  .emblem { margin-top: 6px; filter: drop-shadow(0 8px 10px rgba(0,0,0,.45)); animation: emblemGlow 2.4s ease-in-out infinite; }\n  @keyframes emblemGlow { 0%,100% { transform: scale(1); } 50% { transform: scale(1.06); } }\n  .menu-bottom { position: absolute; left: 0; right: 0; bottom: 70px; display: flex; flex-direction: column; align-items: center; gap: 34px; animation: rise .7s .25s cubic-bezier(.2,1.3,.4,1) both; }\n  @keyframes rise { from { transform: translateY(240px); opacity: 0; } to { transform: none; opacity: 1; } }\n  .menu-stats { display: flex; gap: 16px; }\n  .stat { min-width: 170px; padding: 10px 18px 12px; border-radius: 20px; background: rgba(10,24,56,.72); border: 3px solid rgba(255,255,255,.85); box-shadow: 0 6px 0 rgba(0,0,0,.35), inset 0 2px 0 rgba(255,255,255,.18); text-align: center; backdrop-filter: blur(4px); }\n  .stat-k { font-size: 18px; letter-spacing: 3px; color: #9ec9ff; }\n  .stat-v { font-size: 46px; line-height: 1; margin-top: 4px; color: #fff; text-shadow: 0 3px 0 #0b1e45; }\n  .play { position: relative; overflow: hidden; pointer-events: auto; cursor: pointer; width: 560px; height: 156px; border-radius: 42px; border: 6px solid #fff;\n    background: linear-gradient(180deg, #8cff6b 0%, #3fd24a 48%, #1fa83a 52%, #178a2f 100%); box-shadow: 0 14px 0 #0d5c1e, 0 22px 40px rgba(0,0,0,.5), inset 0 -8px 0 rgba(0,0,0,.12);\n    display: flex; align-items: center; justify-content: center; gap: 22px; font-family: 'Arial Black', Impact, system-ui, sans-serif; animation: playPulse 1.6s ease-in-out infinite; transition: transform .08s; }\n  .play:active { transform: translateY(10px) !important; box-shadow: 0 4px 0 #0d5c1e, 0 10px 20px rgba(0,0,0,.4); animation: none; }\n  @keyframes playPulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.045); } }\n  .play-label { font-size: 76px; color: #fff; letter-spacing: 6px; text-shadow: 0 5px 0 #0d5c1e, -3px -3px 0 #0d5c1e, 3px -3px 0 #0d5c1e, -3px 3px 0 #0d5c1e, 3px 3px 0 #0d5c1e; }\n  .play-chev { font-size: 66px; color: #eaffe0; letter-spacing: -14px; margin-top: -8px; animation: chev 1s linear infinite; }\n  @keyframes chev { 0% { opacity: .35; transform: translateX(-6px); } 50% { opacity: 1; } 100% { opacity: .35; transform: translateX(10px); } }\n  .play-shine { position: absolute; top: -40%; left: -40%; width: 30%; height: 180%; background: linear-gradient(90deg, rgba(255,255,255,0), rgba(255,255,255,.55), rgba(255,255,255,0)); transform: rotate(20deg); animation: shine 2.8s ease-in-out infinite; }\n  @keyframes shine { 0%, 55% { left: -40%; } 100% { left: 130%; } }\n  #hud.in-menu .lvl, #hud.in-menu .prog, #hud.in-menu .hint { visibility: hidden; }\n  .sig { position: absolute; bottom: 14px; left: 0; right: 0; text-align: center; font-size: 22px; color: #ffd23f; opacity: 0; transition: opacity .4s; }\n  .sig.show { opacity: 1; }\n</style>\n</head>\n<body>\n<canvas id=\"c\"></canvas>\n<div id=\"hud\">\n<div class=\"topbar\">\n<div class=\"lvl stroke\" id=\"lvl\">LEVEL 1</div>\n<div class=\"prog\"><div class=\"prog-fill\" id=\"progFill\"></div><div id=\"markers\"></div></div>\n<div class=\"btn-round\" id=\"muteBtn\">\ud83d\udd0a</div>\n</div>\n<div class=\"legend stroke\" id=\"legend\"></div>\n<div id=\"tags\"></div>\n<div id=\"floats\"></div>\n<div class=\"toast stroke\" id=\"toast\"></div>\n<div class=\"hint stroke\" id=\"hint\">\u25c0 DRAG TO STEER \u25b6</div>\n<div class=\"armybar\" id=\"armybar\"></div>\n<div class=\"menu\" id=\"menu\">\n<div class=\"menu-shade top\"></div><div class=\"menu-shade bottom\"></div>\n<div class=\"logo\" id=\"title\">\n<div class=\"logo-word gold\"><span class=\"lw-back\">10,000</span><span class=\"lw-front\">10,000</span></div>\n<div class=\"logo-word white\"><span class=\"lw-back\">ARMY RUNNER</span><span class=\"lw-front\">ARMY RUNNER</span></div>\n<svg class=\"emblem\" height=\"110\" viewbox=\"0 0 320 110\" width=\"320\">\n<defs>\n<lineargradient id=\"emG\" x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\"><stop offset=\"0\" stop-color=\"#fff3a8\"></stop><stop offset=\".45\" stop-color=\"#ffc928\"></stop><stop offset=\"1\" stop-color=\"#e08a00\"></stop></lineargradient>\n<lineargradient id=\"emB\" x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\"><stop offset=\"0\" stop-color=\"#3f8cff\"></stop><stop offset=\"1\" stop-color=\"#1340b0\"></stop></lineargradient>\n</defs>\n<g fill=\"url(#emG)\" stroke=\"#1b1440\" stroke-linejoin=\"round\" stroke-width=\"5\">\n<path d=\"M118 40 L22 26 L30 40 L112 52 Z\"></path><path d=\"M116 58 L36 50 L46 63 L112 70 Z\"></path><path d=\"M118 76 L56 74 L68 86 L118 88 Z\"></path>\n<path d=\"M202 40 L298 26 L290 40 L208 52 Z\"></path><path d=\"M204 58 L284 50 L274 63 L208 70 Z\"></path><path d=\"M202 76 L264 74 L252 86 L202 88 Z\"></path>\n<circle cx=\"160\" cy=\"57\" r=\"44\"></circle>\n</g>\n<circle cx=\"160\" cy=\"57\" fill=\"url(#emB)\" r=\"33\" stroke=\"#1b1440\" stroke-width=\"4\"></circle>\n<path d=\"M160 32 l7.6 15.4 17 2.5 -12.3 12 2.9 16.9 -15.2 -8 -15.2 8 2.9 -16.9 -12.3 -12 17 -2.5z\" fill=\"url(#emG)\" stroke=\"#1b1440\" stroke-linejoin=\"round\" stroke-width=\"3.5\"></path>\n</svg>\n</div>\n<div class=\"menu-bottom\">\n<div class=\"menu-stats\">\n<div class=\"stat\"><div class=\"stat-k\">LEVEL</div><div class=\"stat-v\" id=\"menuLevel\">1</div></div>\n<div class=\"stat\"><div class=\"stat-k\">BEST</div><div class=\"stat-v\" id=\"menuBest\">-</div></div>\n<div class=\"stat\"><div class=\"stat-k\">VICTORIES</div><div class=\"stat-v\" id=\"menuWins\">0</div></div>\n</div>\n<button class=\"play\" id=\"playBtn\"><span class=\"play-shine\"></span><span class=\"play-label\">PLAY</span><span class=\"play-chev\">\u203a\u203a\u203a</span></button>\n</div>\n<div class=\"sig\" id=\"sig\"></div>\n</div>\n<div class=\"overlay hidden\" id=\"winPanel\">\n<div class=\"big stroke\" style=\"color:#ffd23f\">VICTORY!</div>\n<div class=\"sub stroke\" id=\"winText\">Fortress captured</div>\n<div class=\"surv\" id=\"winSurv\"></div>\n<button class=\"btn\" id=\"nextBtn\">NEXT LEVEL</button>\n</div>\n<div class=\"overlay hidden\" id=\"losePanel\">\n<div class=\"big stroke\" style=\"color:#ff4d4f\">DEFEATED</div>\n<div class=\"sub stroke\" id=\"loseText\">Your army was wiped out</div>\n<div class=\"card\" id=\"loseTip\"></div>\n<button class=\"btn\" id=\"retryBtn\">TRY AGAIN</button>\n</div>\n</div>\n<script>\n/*\n * 10,000 ARMY RUNNER: a live military crowd runner.\n * The blue army never stops: steer it through reinforcement gates (soldiers / tanks / helicopters)\n * and into red armies. Every unit fights in real time. Red armies stand in type clusters,\n * so steering into the cluster you counter wins fights.\n * Counter triangle: Soldiers > Helicopters > Tanks > Soldiers.\n * Mid-level Outpost and end-of-level Fortress, each after a reinforcement zone.\n */\n\n// ===================== ARMY RUSH SIM =====================\n// Pure game logic, no rendering: per-unit real-time combat, formations, level layout and the run controller.\n// Shared by the browser game and the headless balance tests.\n\nconst UNIT_TYPES = ['soldier', 'tank', 'heli', 'rocket', 'artillery', 'jet', 'aa', 'mech'];\nconst NT = UNIT_TYPES.length;\nconst UNIT_NAMES = { soldier: 'SOLDIERS', tank: 'TANKS', heli: 'HELICOPTERS', rocket: 'ROCKET TROOPERS', artillery: 'ARTILLERY', jet: 'FIGHTER JETS', aa: 'ANTI-AIR', mech: 'HEAVY MECHS' };\nconst UNIT_SHORT = { soldier: 'SOLDIERS', tank: 'TANKS', heli: 'HELIS', rocket: 'ROCKETS', artillery: 'ARTILLERY', jet: 'JETS', aa: 'ANTI-AIR', mech: 'MECHS' };\n// counter table by type index: S = deals bonus damage to, W = deals reduced damage to\nconst COUNTERS = [\n  { S: [2, 3], W: [1, 4, 5, 7] },        // soldier\n  { S: [0, 6], W: [2, 5, 3] },           // tank\n  { S: [1, 4, 7], W: [0, 5, 6] },        // heli\n  { S: [1, 6, 7], W: [4], V: [[0, 0.12], [3, 0.4], [2, 0.25], [5, 0.25]] }, // rocket: anti-vehicle only\n  { S: [0, 3], W: [2, 5] },              // artillery\n  { S: [2, 4], W: [6] },                 // jet\n  { S: [2, 5], W: [1, 4, 7], V: [[0, 0.3], [3, 0.3]] }, // anti-air\n  { S: [0, 1, 6], W: [3, 2, 5] }         // mech\n];\nconst BEATS_LIST = COUNTERS.map(c => c.S);\nconst BEATS = { soldier: 'heli', tank: 'soldier', heli: 'tank' }; // legacy\nconst ROAD_HALF = 22;\nconst BLUE_MAX_HALF = 10.5;\nconst UNIT_RADIUS = [0.5, 1.6, 2.0, 0.5, 1.8, 2.2, 1.7, 2.4];\nconst UNIT_LAYER = [0, 0, 1, 0, 0, 1, 0, 0];\nconst UNIT_ALT = [0, 0, 4.6, 0, 0, 11, 0, 0];\nconst PROJ_SPEED = [80, 48, 42, 40, 32, 70, 90, 50];\nconst GROUND_SHELL = [0, 1, 0, 0, 1, 0, 0, 1];\nconst BLUE_CAPS = [1200, 120, 80, 400, 40, 50, 50, 24];\nconst RED_CAPS = [800, 80, 50, 300, 30, 40, 40, 16];\nconst SPACING = [0, 1.75, 2.5, 0, 2.1, 2.7, 1.9, 2.8];\nconst IS_INF = [1, 0, 0, 1, 0, 0, 0, 0];\nconst NEW_TYPES = [3, 6, 4, 5, 7];\nconst NEW_AT = [0.08, 0.2, 0.33, 0.47, 0.6];\n\nconst DEFAULT_CONFIG = {\n  runSpeed: 12,\n  steerSensitivity: 1,\n  startSoldiers: 40,\n  difficultyGrowth: 0.09,\n  combatSpeed: 1,\n  enemyStrength: 0.6,\n  strongMultiplier: 1.8,\n  weakMultiplier: 0.55,\n  shadows: true,\n  cameraZoom: 1,\n  cameraPitch: 24,\n  units: {\n    soldier: { hp: 12, damage: 6, interval: 0.6, range: 13, speed: 17, value: 1, splash: 0 },\n    tank: { hp: 125, damage: 55, interval: 1.5, range: 15, speed: 16, value: 9, splash: 2.4 },\n    heli: { hp: 85, damage: 34, interval: 0.8, range: 11, speed: 20, value: 7, splash: 1.5 },\n    rocket: { hp: 18, damage: 55, interval: 1.5, range: 16, speed: 16, value: 1.8, splash: 0 },\n    artillery: { hp: 85, damage: 70, interval: 2.2, range: 22, speed: 13, value: 11, splash: 3.6 },\n    jet: { hp: 80, damage: 32, interval: 0.9, range: 16, speed: 28, value: 8, splash: 1.2 },\n    aa: { hp: 90, damage: 11, interval: 0.28, range: 18, speed: 15, value: 8, splash: 0 },\n    mech: { hp: 900, damage: 95, interval: 1.0, range: 15, speed: 13, value: 40, splash: 2.2 }\n  }\n};\n\n// ---------- utilities ----------\nfunction mulberry32(seed) {\n  let s = seed >>> 0;\n  return function () {\n    s = (s + 0x6D2B79F5) >>> 0;\n    let t = s;\n    t = Math.imul(t ^ (t >>> 15), t | 1);\n    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\nfunction hashString(str) {\n  let h = 2166136261 >>> 0;\n  for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; }\n  return h >>> 0;\n}\nconst CANT_AIM_UP = [0, 1, 0, 0, 1, 0, 0, 1];\nfunction counterMultK(cfg, a, d) {\n  const v = COUNTERS[a].V && COUNTERS[a].V.find(p => p[0] === d);\n  if (v) return v[1];\n  if (COUNTERS[a].S.includes(d)) return cfg.strongMultiplier;\n  if (COUNTERS[a].W.includes(d)) return cfg.weakMultiplier * (CANT_AIM_UP[a] && UNIT_LAYER[d] === 1 ? 0.55 : 1);\n  return 1;\n}\nconst zeros = () => new Array(NT).fill(0);\nconst sum = a => a.reduce((x, y) => x + y, 0);\n\n// ---------- aggregate advisor (used for level budgets and the autopilot) ----------\nfunction armyValueC(c, cfg) { return UNIT_TYPES.reduce((s, t, k) => s + (c[k] || 0) * cfg.units[t].value, 0); }\nfunction adviseBattle(A, B, cfg) {\n  const a = A.slice(), b = B.slice(), da = zeros(), db = zeros();\n  const dps = UNIT_TYPES.map(t => cfg.units[t].damage / cfg.units[t].interval * (1 + (cfg.units[t].splash || 0) * 0.35));\n  const hp = UNIT_TYPES.map(t => cfg.units[t].hp);\n  const step = (att, def, out) => {\n    for (let x = 0; x < NT; x++) {\n      if (!att[x]) continue;\n      let sw = 0; const w = zeros();\n      for (let y = 0; y < NT; y++) if (def[y] > 0) { const m = counterMultK(cfg, x, y); w[y] = m * m * def[y] * hp[y]; sw += w[y]; }\n      if (!sw) continue;\n      for (let y = 0; y < NT; y++) if (w[y]) out[y] += att[x] * dps[x] * 0.05 * (w[y] / sw) * counterMultK(cfg, x, y);\n    }\n  };\n  for (let it = 0; it < 6000; it++) {\n    if (sum(a) <= 0 || sum(b) <= 0) break;\n    const ia = zeros(), ib = zeros();\n    step(a, b, ib); step(b, a, ia);\n    for (let y = 0; y < NT; y++) {\n      da[y] += ia[y]; db[y] += ib[y];\n      const ka = Math.min(a[y], Math.floor(da[y] / hp[y])); a[y] -= ka; da[y] -= ka * hp[y];\n      const kb = Math.min(b[y], Math.floor(db[y] / hp[y])); b[y] -= kb; db[y] -= kb * hp[y];\n    }\n  }\n  return { aWins: sum(a) > 0 && sum(b) <= 0, A: a, B: b };\n}\n\n// ---------- level generation ----------\nfunction countsFromValue(value, w, cfg) {\n  const sw = sum(w) || 1;\n  const c = UNIT_TYPES.map((t, k) => Math.round(value * (w[k] || 0) / sw / cfg.units[t].value));\n  if (sum(c) === 0) c[0] = Math.max(1, Math.round(value));\n  return c;\n}\nfunction generateLevel(level, cfg, salt) {\n  const rng = mulberry32(hashString('lvl' + level + ':' + salt));\n  const events = [];\n  let N = cfg.startSoldiers;\n  const scale = 1 + cfg.difficultyGrowth * (level - 1);\n  const pairs = 5 + Math.min(4, Math.floor((level - 1) / 3));\n  const totalEvents = 3 * pairs * 2 + 2 * 3 + 4;\n  const introScale = Math.max(0, 1 - (level - 1) * 0.34);\n  const unlockAt = {}; NEW_TYPES.forEach((k, i) => unlockAt[k] = Math.floor(NEW_AT[i] * introScale * totalEvents));\n  const introduced = new Set([0, 1, 2]);\n  const pool = () => [0, 1, 2].concat(NEW_TYPES.filter(k => events.length >= unlockAt[k]));\n  const shuffle = a => { for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(rng() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; };\n  function gateRow(bigger) {\n    const g = (bigger ? 18 + 0.4 * N : 12 + 0.26 * N) * (0.95 + rng() * 0.1);\n    const nGates = rng() < 0.35 ? 3 : 2;\n    let avail = pool().filter(k => cfg.units[UNIT_TYPES[k]].value <= g * 1.1);\n    const fresh = avail.filter(k => !introduced.has(k));\n    let types = shuffle(avail.filter(k => introduced.has(k)));\n    if (fresh.length) types = [fresh[0]].concat(types);\n    types = types.slice(0, nGates);\n    if (types.length < 2) types = [0, 1].slice(0, 2);\n    let newType = -1;\n    const gates = types.map(k => {\n      if (!introduced.has(k)) { introduced.add(k); newType = k; }\n      const c = zeros();\n      c[k] = Math.max(1, Math.round(g * (0.9 + rng() * 0.2) / cfg.units[UNIT_TYPES[k]].value));\n      return { units: c, main: k, mixed: false };\n    });\n    shuffle(gates);\n    events.push({ kind: 'gates', gates, newType });\n    N += g;\n  }\n  function enemy(tier) {\n    const mult = tier === 'fortress' ? 0.9 : tier === 'outpost' ? 0.8 : 0.5;\n    const value = Math.max(8, N * mult * (cfg.enemyStrength || 1) * Math.min(1.3, 0.85 + 0.06 * level) * scale / (1 + 0.1 * (level - 1)) * (0.92 + rng() * 0.16));\n    const p = shuffle(pool().filter(k => introduced.has(k) && cfg.units[UNIT_TYPES[k]].value <= value * 0.6));\n    const nTypes = Math.min(p.length, tier === 'normal' ? 1 + Math.floor(rng() * 2) : 2 + Math.floor(rng() * 2));\n    const w = zeros();\n    p.slice(0, Math.max(1, nTypes)).forEach((k, i) => w[k] = i === 0 ? 1 : 0.25 + rng() * 0.5);\n    const counts = countsFromValue(value, w, cfg);\n    const order = shuffle([...Array(NT).keys()]);\n    events.push({ kind: 'enemy', tier, counts, order });\n    N -= value * (tier === 'normal' ? 0.55 : 0.65);\n    N = Math.max(N, 20);\n  }\n  // three phases (two outposts, then the fortress), each with a reinforcement zone\n  for (let phase = 0; phase < 3; phase++) {\n    for (let i = 0; i < pairs; i++) { gateRow(false); enemy('normal'); }\n    if (phase < 2) { gateRow(true); gateRow(true); enemy('outpost'); }\n  }\n  gateRow(true); gateRow(true); gateRow(true);\n  enemy('fortress');\n  return events;\n}\n\n// ---------- unit store ----------\nconst MAXU = 7000;\nconst U = {\n  x: new Float32Array(MAXU), z: new Float32Array(MAXU), vx: new Float32Array(MAXU), vz: new Float32Array(MAXU),\n  yaw: new Float32Array(MAXU), hp: new Float32Array(MAXU), cool: new Float32Array(MAXU), retar: new Float32Array(MAXU),\n  tx: new Float32Array(MAXU), tz: new Float32Array(MAXU), fireT: new Float32Array(MAXU), hitT: new Float32Array(MAXU),\n  ph: new Float32Array(MAXU), spd: new Float32Array(MAXU), born: new Float32Array(MAXU), pend: new Float32Array(MAXU),\n  type: new Uint8Array(MAXU), team: new Uint8Array(MAXU), alive: new Uint8Array(MAXU), engaged: new Uint8Array(MAXU),\n  group: new Int16Array(MAXU), target: new Int32Array(MAXU), slot: new Int32Array(MAXU), gen: new Uint32Array(MAXU),\n  hi: 0, free: [], pendingFree: []\n};\nlet SIMCFG = null, simTime = 0;\nconst MULT = new Float32Array(NT * NT);\nconst groups = [];\nconst hits = [];\nconst SIMCB = { onShot: null, onKill: null, onSpawn: null };\n\nfunction simReset(cfg) {\n  SIMCFG = cfg; simTime = 0;\n  U.alive.fill(0); U.hi = 0; U.free.length = 0; U.pendingFree.length = 0;\n  groups.length = 0; hits.length = 0;\n}\nfunction allocUnit() {\n  if (U.free.length) return U.free.pop();\n  if (U.hi < MAXU) return U.hi++;\n  return -1;\n}\nfunction makeGroup(team, kind, cx, cz) {\n  const g = {\n    id: groups.length, team, kind, tier: 'normal', cx, cz, lists: UNIT_TYPES.map(() => []), reserve: zeros(), pending: zeros(),\n    order: [...Array(NT).keys()], offsets: UNIT_TYPES.map(() => new Float32Array(0)), dirty: true,\n    halfW: 1, halfD: 1, clusterX: zeros(), awake: team === 0, alerted: team === 0, spawned: team === 0, alive: true, caps: team === 0 ? BLUE_CAPS : RED_CAPS\n  };\n  groups.push(g);\n  return g;\n}\nfunction groupTotal(g, k) { return g.lists[k].length + g.reserve[k] + g.pending[k]; }\nfunction groupCount(g) { let n = 0; for (let k = 0; k < NT; k++) n += groupTotal(g, k); return n; }\nfunction spawnUnit(g, k, x, z) {\n  const i = allocUnit(); if (i < 0) { g.reserve[k]++; return -1; }\n  const t = UNIT_TYPES[k], uc = SIMCFG.units[t];\n  U.alive[i] = 1; U.type[i] = k; U.team[i] = g.team; U.group[i] = g.id; U.gen[i]++;\n  U.x[i] = x; U.z[i] = z; U.vx[i] = 0; U.vz[i] = 0; U.tx[i] = x; U.tz[i] = z;\n  U.yaw[i] = g.team === 0 ? 0 : Math.PI; U.hp[i] = uc.hp; U.cool[i] = Math.random() * uc.interval; U.retar[i] = Math.random() * 0.3;\n  U.fireT[i] = 9; U.hitT[i] = 9; U.pend[i] = 0; U.ph[i] = Math.random() * 6.283; U.spd[i] = 0; U.target[i] = -1; U.engaged[i] = 0; U.born[i] = simTime;\n  U.slot[i] = g.lists[k].length; g.lists[k].push(i); g.dirty = true;\n  if (SIMCB.onSpawn) SIMCB.onSpawn(i);\n  return i;\n}\n// Add units to a group: fills the visible cap first, the rest wait in reserve.\nfunction addUnits(g, k, n, x, z, spread) {\n  for (let c = 0; c < n; c++) {\n    if (g.lists[k].length < g.caps[k]) spawnUnit(g, k, x + (Math.random() - 0.5) * spread, z + (Math.random() - 0.5) * spread * 0.6);\n    else g.reserve[k]++;\n  }\n}\nfunction killUnit(i, attackerType) {\n  if (!U.alive[i]) return;\n  U.alive[i] = 0;\n  const g = groups[U.group[i]], k = U.type[i], list = g.lists[k], s = U.slot[i];\n  const last = list.pop();\n  if (last !== i) { list[s] = last; U.slot[last] = s; }\n  g.dirty = true;\n  U.pendingFree.push(i);\n  if (SIMCB.onKill) SIMCB.onKill(i, attackerType);\n  if (g.reserve[k] > 0) {\n    g.reserve[k]--;\n    const rearZ = g.cz + (g.team === 0 ? 1 : -1) * (g.halfD + 2);\n    spawnUnit(g, k, g.cx + (Math.random() - 0.5) * g.halfW * 1.6, rearZ);\n  }\n}\n\n// ---------- formations ----------\nconst GA = 2.39996323;\nconst HEAVY_ORDER = [7, 1, 6, 4];\nfunction clusterRadius(k, n) { if (!n) return 0; return IS_INF[k] ? Math.sqrt(0.3 * n) + 0.6 : SPACING[k] * Math.sqrt(n) + 1; }\nfunction computeOffsets(g) {\n  const n = g.lists.map(l => l.length);\n  for (let k = 0; k < NT; k++) if (g.offsets[k].length < n[k] * 2) g.offsets[k] = new Float32Array(Math.max(16, n[k] * 2 + 64));\n  if (g.team === 0) {\n    // heavy vehicles in the middle, infantry around them, aircraft above and behind\n    let nh = 0; for (const k of HEAVY_ORDER) nh += n[k];\n    const Rt = nh > 0 ? 1.9 * Math.sqrt(nh) + 1.0 : 0;\n    const ni = n[0] + n[3];\n    const Rs = ni > 0 ? Math.sqrt(Rt * Rt + 0.3 * ni) + 0.3 : 0;\n    const na = n[2] + n[5];\n    const Rh = na > 0 ? 2.6 * Math.sqrt(na) + 1 : 0;\n    const R = Math.max(Rt, Rs, Rh, 1);\n    const sx = R > BLUE_MAX_HALF ? BLUE_MAX_HALF / R : 1, sz = 1 / sx;\n    let idx = 0;\n    for (const k of HEAVY_ORDER) { const o = g.offsets[k]; for (let i = 0; i < n[k]; i++, idx++) { const r = nh > 1 ? 1.9 * Math.sqrt(idx + 0.5) : 0, a = idx * GA; o[i * 2] = Math.cos(a) * r * sx; o[i * 2 + 1] = Math.sin(a) * r * sz; } }\n    idx = 0;\n    for (const k of [3, 0]) { const o = g.offsets[k]; for (let i = 0; i < n[k]; i++, idx++) { const r = Math.sqrt(Rt * Rt + 0.3 * (idx + 0.5)), a = idx * GA; o[i * 2] = Math.cos(a) * r * sx; o[i * 2 + 1] = Math.sin(a) * r * sz; } }\n    const rear = R * 0.25 * sz;\n    idx = 0;\n    for (const k of [2, 5]) { const o = g.offsets[k]; for (let i = 0; i < n[k]; i++, idx++) { const r = na > 1 ? 2.6 * Math.sqrt(idx + 0.5) : 0, a = idx * GA + 1; o[i * 2] = Math.cos(a) * r * sx; o[i * 2 + 1] = rear + Math.sin(a) * r * sz; } }\n    g.halfW = R * sx; g.halfD = R * sz;\n    g.clusterX = zeros();\n  } else {\n    // red: type clusters side by side across the battlefield\n    const rad = n.map((c, k) => clusterRadius(k, c));\n    const present = g.order.filter(k => rad[k] > 0);\n    let W = 0; for (const k of present) W += rad[k] * 2; W += Math.max(0, present.length - 1) * 3;\n    const maxW = ROAD_HALF * 2 - 4;\n    const sx = W > maxW ? maxW / W : 1, sz = 1 / sx;\n    let cur = -W * sx / 2; let maxR = 1;\n    for (const k of present) {\n      const cxk = cur + rad[k] * sx; g.clusterX[k] = cxk; cur += rad[k] * 2 * sx + 3 * sx;\n      maxR = Math.max(maxR, rad[k]);\n      const o = g.offsets[k];\n      for (let i = 0; i < n[k]; i++) {\n        const r = IS_INF[k] ? Math.sqrt(0.3 * (i + 0.5)) : (n[k] > 1 ? SPACING[k] * Math.sqrt(i + 0.5) : 0);\n        const a = i * GA;\n        o[i * 2] = cxk + Math.cos(a) * r * sx; o[i * 2 + 1] = Math.sin(a) * r * sz;\n      }\n    }\n    g.halfW = Math.max(1, W * sx / 2); g.halfD = maxR * sz;\n  }\n  g.dirty = false;\n}\n// Formation size for an army that is not spawned yet (used for layout)\nfunction previewHalfD(counts, team, order) {\n  const g = { team, lists: counts.map((c, k) => new Array(Math.min(c, RED_CAPS[k]))), order: order || [...Array(NT).keys()], offsets: UNIT_TYPES.map(() => new Float32Array(0)), clusterX: zeros() };\n  computeOffsets(g);\n  return { halfD: g.halfD, halfW: g.halfW, clusterX: g.clusterX };\n}\n\n// ---------- spatial grids ----------\n// Coarse grid (8 units) for targeting/splash with per-team counts; fine grid (2 units) for crowd separation.\nconst GX0 = -ROAD_HALF - 16, GW = ROAD_HALF * 2 + 32;\nfunction makeGrid(cell) {\n  return { cell, cols: Math.ceil(GW / cell), rows: 0, z0: 0, start: new Int32Array(1), count: new Int32Array(1), team: new Int32Array(2), items: new Int32Array(MAXU), uc: new Int32Array(MAXU) };\n}\nconst GRID = makeGrid(8), FINE = makeGrid(2);\nfunction fillGrid(G, zmin, zmax) {\n  G.z0 = zmin - 1; G.rows = Math.ceil((zmax - G.z0 + 1) / G.cell) + 1;\n  const nc = G.cols * G.rows;\n  if (G.start.length < nc + 1) { G.start = new Int32Array(nc + 256); G.count = new Int32Array(nc + 256); G.team = new Int32Array((nc + 256) * 2); }\n  G.count.fill(0, 0, nc + 1); G.team.fill(0, 0, nc * 2 + 2);\n  for (let i = 0; i < U.hi; i++) {\n    if (!U.alive[i] || !groups[U.group[i]].awake) { G.uc[i] = -1; continue; }\n    const cx = Math.max(0, Math.min(G.cols - 1, Math.floor((U.x[i] - GX0) / G.cell)));\n    const cz = Math.max(0, Math.min(G.rows - 1, Math.floor((U.z[i] - G.z0) / G.cell)));\n    const c = cz * G.cols + cx; G.uc[i] = c; G.count[c]++; G.team[c * 2 + U.team[i]]++;\n  }\n  let acc = 0;\n  for (let c = 0; c < nc; c++) { G.start[c] = acc; acc += G.count[c]; G.count[c] = 0; }\n  G.start[nc] = acc;\n  for (let i = 0; i < U.hi; i++) { const c = G.uc[i]; if (c < 0) continue; G.items[G.start[c] + G.count[c]++] = i; }\n}\nlet gridOk = false;\nfunction buildGrid() {\n  let zmin = 1e9, zmax = -1e9, n = 0;\n  for (let i = 0; i < U.hi; i++) {\n    if (!U.alive[i] || !groups[U.group[i]].awake) continue;\n    const z = U.z[i]; if (z < zmin) zmin = z; if (z > zmax) zmax = z; n++;\n  }\n  gridOk = n > 0;\n  if (!gridOk) return;\n  fillGrid(GRID, zmin, zmax); fillGrid(FINE, zmin, zmax);\n}\nfunction cellRange(G, x, z, r) {\n  return [Math.max(0, Math.floor((x - r - GX0) / G.cell)), Math.min(G.cols - 1, Math.floor((x + r - GX0) / G.cell)),\n    Math.max(0, Math.floor((z - r - G.z0) / G.cell)), Math.min(G.rows - 1, Math.floor((z + r - G.z0) / G.cell))];\n}\nfunction forCells(x, z, r, fn) {\n  if (!gridOk) return;\n  const G = GRID, [c0, c1, r0, r1] = cellRange(G, x, z, r);\n  for (let rz = r0; rz <= r1; rz++) for (let cx = c0; cx <= c1; cx++) {\n    const c = rz * G.cols + cx, s = G.start[c], e = s + G.count[c];\n    for (let p = s; p < e; p++) if (fn(G.items[p]) === false) return;\n  }\n}\n\n// ---------- combat ----------\nfunction findTarget(i, range) {\n  if (!gridOk) return -1;\n  const G = GRID, x = U.x[i], z = U.z[i], team = U.team[i], other = 1 - team, k = U.type[i], r2 = range * range;\n  const c0 = Math.max(0, Math.floor((x - range - GX0) / G.cell)), c1 = Math.min(G.cols - 1, Math.floor((x + range - GX0) / G.cell));\n  const r0 = Math.max(0, Math.floor((z - range - G.z0) / G.cell)), r1 = Math.min(G.rows - 1, Math.floor((z + range - G.z0) / G.cell));\n  let best = -1, bs = 1e18, checked = 0;\n  for (let rz = r0; rz <= r1; rz++) for (let cx = c0; cx <= c1; cx++) {\n    const c = rz * G.cols + cx;\n    if (G.team[c * 2 + other] === 0) continue;\n    const s = G.start[c], e = s + G.count[c];\n    for (let p = s; p < e; p++) {\n      const j = G.items[p];\n      if (U.team[j] === team || !U.alive[j]) continue;\n      const dx = U.x[j] - x, dz = U.z[j] - z, d2 = dx * dx + dz * dz;\n      if (d2 > r2) continue;\n      const m = MULT[k * NT + U.type[j]], sc = d2 / (m * m) * (U.pend[j] >= U.hp[j] ? 25 : 1);\n      if (sc < bs) { bs = sc; best = j; }\n      if (++checked > 40) return best;\n    }\n  }\n  return best;\n}\nfunction separation(i, out) {\n  const G = FINE, x = U.x[i], z = U.z[i], ri = UNIT_RADIUS[U.type[i]], layer = UNIT_LAYER[U.type[i]];\n  const reach = ri + 1.5;\n  const c0 = Math.max(0, Math.floor((x - reach - GX0) / G.cell)), c1 = Math.min(G.cols - 1, Math.floor((x + reach - GX0) / G.cell));\n  const r0 = Math.max(0, Math.floor((z - reach - G.z0) / G.cell)), r1 = Math.min(G.rows - 1, Math.floor((z + reach - G.z0) / G.cell));\n  let px = 0, pz = 0, n = 0;\n  for (let rz = r0; rz <= r1; rz++) for (let cx = c0; cx <= c1; cx++) {\n    const c = rz * G.cols + cx, s = G.start[c], e = s + G.count[c];\n    for (let p = s; p < e; p++) {\n      const j = G.items[p];\n      if (j === i || UNIT_LAYER[U.type[j]] !== layer) continue;\n      const dx = x - U.x[j], dz = z - U.z[j], d2 = dx * dx + dz * dz, rr = ri + UNIT_RADIUS[U.type[j]];\n      if (d2 >= rr * rr || d2 < 1e-6) continue;\n      const d = Math.sqrt(d2), f = (rr - d) / d;\n      px += dx * f; pz += dz * f;\n      if (++n > 8) { out[0] = px; out[1] = pz; return; }\n    }\n  }\n  out[0] = px; out[1] = pz;\n}\nfunction fire(i, j, dist) {\n  const k = U.type[i], uc = SIMCFG.units[UNIT_TYPES[k]];\n  const dmg = uc.damage * MULT[k * NT + U.type[j]];\n  const flight = dist / PROJ_SPEED[k];\n  hits.push({ j, gen: U.gen[j], dmg, t: flight, k, x: U.x[j], z: U.z[j], team: U.team[i], splash: uc.splash || 0 });\n  U.pend[j] += dmg;\n  U.fireT[i] = 0;\n  if (SIMCB.onShot) SIMCB.onShot(i, j, flight);\n}\nfunction damageUnit(j, dmg, k) {\n  if (!U.alive[j]) return;\n  U.hp[j] -= dmg; U.hitT[j] = 0;\n  if (U.hp[j] <= 0) killUnit(j, k);\n}\nlet _sx, _sz, _sr2, _steam, _sk, _sdmg, _sskip, _sGroundOnly;\nfunction _splashHit(j) {\n  if (j === _sskip || U.team[j] === _steam || !U.alive[j]) return;\n  if (_sGroundOnly && UNIT_LAYER[U.type[j]] === 1) return;\n  const dx = U.x[j] - _sx, dz = U.z[j] - _sz;\n  if (dx * dx + dz * dz > _sr2) return;\n  damageUnit(j, _sdmg * MULT[_sk * NT + U.type[j]], _sk);\n}\nfunction processHits(dt) {\n  if (!gridOk && hits.length) { /* grid empty: splash scans nothing */ }\n  for (let h = hits.length - 1; h >= 0; h--) {\n    const H = hits[h];\n    H.t -= dt;\n    if (U.alive[H.j] && U.gen[H.j] === H.gen) { H.x = U.x[H.j]; H.z = U.z[H.j]; }\n    if (H.t > 0) continue;\n    hits[h] = hits[hits.length - 1]; hits.pop();\n    const valid = U.alive[H.j] && U.gen[H.j] === H.gen;\n    if (valid) U.pend[H.j] = Math.max(0, U.pend[H.j] - H.dmg);\n    if (H.splash > 0) {\n      _sx = H.x; _sz = H.z; _sr2 = H.splash * H.splash; _steam = H.team; _sk = H.k; _sdmg = H.dmg / MULT[H.k * NT + (valid ? U.type[H.j] : 0)] * 0.55;\n      _sskip = valid ? H.j : -1; _sGroundOnly = GROUND_SHELL[H.k] === 1;\n      forCells(H.x, H.z, H.splash, _splashHit);\n    }\n    if (valid) damageUnit(H.j, H.dmg, H.k);\n    if (SIMCB.onImpact) SIMCB.onImpact(H.k, H.x, H.z, valid ? U.type[H.j] : 0);\n  }\n}\n\nconst _push = new Float32Array(2);\n\nlet engagedBlue = 0, engagedRed = 0;\nfunction simUpdate(dt) {\n  simTime += dt;\n  const cfg = SIMCFG;\n  for (let a = 0; a < NT; a++) for (let d = 0; d < NT; d++) MULT[a * NT + d] = counterMultK(cfg, a, d);\n  // formation targets\n  for (const g of groups) {\n    if (!g.alive || !g.spawned) continue;\n    if (g.dirty) computeOffsets(g);\n    for (let k = 0; k < NT; k++) {\n      const list = g.lists[k], o = g.offsets[k];\n      for (let s = 0; s < list.length; s++) { const i = list[s]; U.tx[i] = g.cx + o[s * 2]; U.tz[i] = g.cz + o[s * 2 + 1]; }\n    }\n  }\n  buildGrid();\n  engagedBlue = 0; engagedRed = 0;\n  const cs = cfg.combatSpeed;\n  const ranges = UNIT_TYPES.map(t => cfg.units[t].range), speeds = UNIT_TYPES.map(t => Math.max(cfg.units[t].speed, cfg.runSpeed + 6)), ints = UNIT_TYPES.map(t => cfg.units[t].interval);\n  for (let i = 0; i < U.hi; i++) {\n    if (!U.alive[i]) continue;\n    U.fireT[i] += dt; U.hitT[i] += dt;\n    const g = groups[U.group[i]];\n    const k = U.type[i], R = ranges[k];\n    let dx = U.tx[i] - U.x[i], dz = U.tz[i] - U.z[i];\n    let engaged = false, tdx = 0, tdz = 0, td = 0;\n    if (g.awake) {\n      U.retar[i] -= dt;\n      let j = U.target[i];\n      if (j >= 0 && (!U.alive[j] || U.team[j] === U.team[i])) j = -1;\n      if (j >= 0 && U.pend[j] >= U.hp[j] * 1.3) U.retar[i] = 0;\n      if (U.retar[i] <= 0 || j < 0) { j = findTarget(i, g.alerted ? R + 14 : R); U.retar[i] = 0.25 + Math.random() * 0.25; }\n      U.target[i] = j;\n      if (j >= 0) {\n        tdx = U.x[j] - U.x[i]; tdz = U.z[j] - U.z[i]; td = Math.sqrt(tdx * tdx + tdz * tdz);\n        if (td <= R) engaged = true;\n        if (U.team[i] === 1 && g.alerted && !engaged) {\n          // red units charge at their target\n          const f = (td - R * 0.75) / Math.max(td, 0.001); dx = tdx * f; dz = tdz * f;\n        }\n      }\n      if (engaged) {\n        if (U.team[i] === 0) { engagedBlue++; dx *= 0.3; dz *= 0.3; } else { engagedRed++; dx *= 0.15; dz *= 0.15; }\n        U.cool[i] -= dt * cs;\n        if (U.cool[i] <= 0) { U.cool[i] = ints[k] * (0.85 + Math.random() * 0.3); fire(i, j, td); }\n      }\n    }\n    // steering velocity\n    let dvx = dx * 4, dvz = dz * 4;\n    const sp = speeds[k], m2 = dvx * dvx + dvz * dvz;\n    if (m2 > sp * sp) { const f = sp / Math.sqrt(m2); dvx *= f; dvz *= f; }\n    if (g.awake && gridOk) {\n      separation(i, _push);\n      dvx += _push[0] * 6; dvz += _push[1] * 6;\n    }\n    const a = Math.min(1, dt * 9);\n    U.vx[i] += (dvx - U.vx[i]) * a; U.vz[i] += (dvz - U.vz[i]) * a;\n    U.x[i] += U.vx[i] * dt; U.z[i] += U.vz[i] * dt;\n    if (U.x[i] > ROAD_HALF + 6) U.x[i] = ROAD_HALF + 6; else if (U.x[i] < -ROAD_HALF - 6) U.x[i] = -ROAD_HALF - 6;\n    const spd = Math.sqrt(U.vx[i] * U.vx[i] + U.vz[i] * U.vz[i]); U.spd[i] = spd;\n    // facing\n    let want = U.yaw[i];\n    if (engaged) want = Math.atan2(-tdx, -tdz);\n    else if (spd > 1.5) want = Math.atan2(-U.vx[i], -U.vz[i]);\n    else want = U.team[i] === 0 ? 0 : Math.PI;\n    let dy = want - U.yaw[i]; dy = Math.atan2(Math.sin(dy), Math.cos(dy));\n    U.yaw[i] += dy * Math.min(1, dt * (GROUND_SHELL[k] || k === 6 ? 3 : 8));\n    U.engaged[i] = engaged ? 1 : 0;\n  }\n  processHits(dt);\n  for (const i of U.pendingFree) U.free.push(i);\n  U.pendingFree.length = 0;\n  for (const g of groups) if (g.alive && g.spawned && groupCount(g) === 0) g.alive = false;\n}\n\n// ---------- run controller ----------\nconst RUN = {\n  state: 'idle', level: 1, events: [], rows: [], enemies: [], player: null, pcx: 0, pcz: 0, targetCx: 0, speed: 0,\n  fortressZ: -500, cb: {}, gateLog: []\n};\nfunction runStart(level, cfg, salt) {\n  simReset(cfg);\n  RUN.level = level; RUN.state = 'ready'; RUN.rows = []; RUN.enemies = []; RUN.gateLog = [];\n  RUN.events = generateLevel(level, cfg, salt);\n  RUN.pcx = 0; RUN.pcz = 0; RUN.targetCx = 0; RUN.speed = 0;\n  const p = RUN.player = makeGroup(0, 'player', 0, 0);\n  addUnits(p, 0, Math.max(1, Math.round(cfg.startSoldiers)), 0, 0, 6);\n  computeOffsets(p);\n  for (let k = 0; k < NT; k++) for (const i of p.lists[k]) { U.x[i] = p.cx + p.offsets[k][U.slot[i] * 2]; U.z[i] = p.cz + p.offsets[k][U.slot[i] * 2 + 1]; }\n  let z = -55;\n  const evs = RUN.events;\n  for (let e = 0; e < evs.length; e++) {\n    const ev = evs[e];\n    if (ev.kind === 'gates') {\n      const n = ev.gates.length, w = ROAD_HALF * 2 / n;\n      const prev = evs[e - 1], next = evs[e + 1];\n      let warn = null;\n      if (next && next.kind === 'gates' && (!prev || prev.kind !== 'gates')) { for (let q = e + 1; q < evs.length; q++) if (evs[q].kind === 'enemy') { warn = evs[q].tier; break; } }\n      RUN.rows.push({ z, used: false, chosen: -1, warn, warned: false, newType: ev.newType, newWarned: false, gates: ev.gates.map((gd, i) => ({ ...gd, x0: -ROAD_HALF + i * w, x1: -ROAD_HALF + (i + 1) * w })) });\n      z -= 52;\n    } else {\n      const pv = previewHalfD(ev.counts, 1, ev.order);\n      const ez = z - pv.halfD;\n      const g = makeGroup(1, 'enemy', 0, ez);\n      g.tier = ev.tier; g.order = ev.order; g.pending = ev.counts.slice(); g.homeZ = ez;\n      g.halfD = pv.halfD; g.halfW = pv.halfW; g.clusterX = pv.clusterX;\n      RUN.enemies.push(g);\n      if (ev.tier === 'fortress') RUN.fortressZ = ez - pv.halfD - 14;\n      z = ez - pv.halfD - 55;\n    }\n  }\n}\nfunction blueFront() { return RUN.pcz - RUN.player.halfD; }\nfunction runUpdate(dt) {\n  const cfg = SIMCFG, P = RUN.player;\n  if (RUN.state !== 'run' && RUN.state !== 'won' && RUN.state !== 'lost') return;\n  const playing = RUN.state === 'run';\n  // steering: army stays inside the battlefield, never wider than about half of it\n  const lim = Math.max(0, ROAD_HALF - 1 - P.halfW);\n  RUN.targetCx = Math.max(-lim, Math.min(lim, RUN.targetCx));\n  RUN.pcx += (RUN.targetCx - RUN.pcx) * Math.min(1, dt * 8);\n  // speed: crawl while fighting so battles play out, sprint otherwise\n  const want = !playing ? 0 : (engagedBlue > 0 ? 2.2 : cfg.runSpeed);\n  RUN.speed += (want - RUN.speed) * Math.min(1, dt * (want < RUN.speed ? 4 : 1.5));\n  RUN.pcz -= RUN.speed * dt;\n  P.cx = RUN.pcx; P.cz = RUN.pcz;\n  // gates\n  if (playing) for (let r = 0; r < RUN.rows.length; r++) {\n    const row = RUN.rows[r];\n    if (row.warn && !row.warned && RUN.pcz - row.z < 70) { row.warned = true; if (RUN.cb.onWarn) RUN.cb.onWarn(row.warn); }\n    if (row.newType >= 0 && !row.newWarned && RUN.pcz - row.z < 85) { row.newWarned = true; if (RUN.cb.onNewType) RUN.cb.onNewType(row.newType); }\n    if (row.used || RUN.pcz > row.z) continue;\n    row.used = true;\n    let idx = row.gates.findIndex(g => RUN.pcx >= g.x0 && RUN.pcx < g.x1);\n    if (idx < 0) idx = RUN.pcx < 0 ? 0 : row.gates.length - 1;\n    row.chosen = idx;\n    const gd = row.gates[idx];\n    RUN.gateLog.push(idx);\n    for (let k = 0; k < NT; k++) if (gd.units[k]) addUnits(P, k, gd.units[k], RUN.pcx, row.z - 2, Math.min(14, 4 + Math.sqrt(gd.units[k])));\n    if (RUN.cb.onGate) RUN.cb.onGate(row, idx);\n  }\n  // enemies: spawn, wake, alert, charge\n  const bf = blueFront();\n  for (const g of RUN.enemies) {\n    if (!g.alive) continue;\n    if (!g.spawned && RUN.pcz - g.cz < 190) {\n      g.spawned = true;\n      for (let k = 0; k < NT; k++) { const n = g.pending[k]; g.pending[k] = 0; addUnits(g, k, n, 0, g.cz, 0); }\n      computeOffsets(g);\n      for (let k = 0; k < NT; k++) for (const i of g.lists[k]) { U.x[i] = g.cx + g.offsets[k][U.slot[i] * 2]; U.z[i] = g.cz + g.offsets[k][U.slot[i] * 2 + 1]; U.tx[i] = U.x[i]; U.tz[i] = U.z[i]; }\n      if (RUN.cb.onEnemySpawn) RUN.cb.onEnemySpawn(g);\n    }\n    if (!g.spawned) continue;\n    const gap = bf - (g.cz + g.halfD);\n    if (!g.awake && gap < 70) g.awake = true;\n    if (!g.alerted && gap < 32) { g.alerted = true; if (RUN.cb.onAlert) RUN.cb.onAlert(g); }\n    if (g.alerted && gap > 3) g.cz += Math.min(gap - 3, 5 * dt);\n    if (g.alerted) g.cx += (RUN.pcx * 0.25 - g.cx) * Math.min(1, dt * 0.5);\n  }\n  simUpdate(dt);\n  // outcomes\n  for (const g of RUN.enemies) {\n    if (!g.alive && !g.reported) {\n      g.reported = true;\n      if (RUN.cb.onEnemyDead) RUN.cb.onEnemyDead(g);\n      if (g.tier === 'fortress' && RUN.state === 'run') { RUN.state = 'won'; if (RUN.cb.onWin) RUN.cb.onWin(); }\n    }\n  }\n  if (RUN.state === 'run' && groupCount(P) === 0) { RUN.state = 'lost'; if (RUN.cb.onLose) RUN.cb.onLose(); }\n}\nfunction nextEnemyGroup() { return RUN.enemies.find(g => g.alive) || null; }\nfunction groupCounts(g) { const c = zeros(); for (let k = 0; k < NT; k++) c[k] = groupTotal(g, k); return c; }\n\nif (typeof module !== 'undefined') {\n  module.exports = { simReset, simUpdate, makeGroup, addUnits, computeOffsets, groupTotal, UNIT_TYPES, NT, COUNTERS, BEATS, DEFAULT_CONFIG, counterMultK, RUN, U, groups, runStart, runUpdate, groupCounts, groupCount, nextEnemyGroup, adviseBattle, armyValueC, engaged: () => engagedBlue };\n}\n\n\n// ============ constants ============\nconst SALT = String.fromCharCode(66, 76, 79, 67, 75, 72, 69, 65, 68, 90) + '|ARMYRUSH';\nconst TYPE_COLOR = { soldier: '#22b14c', tank: '#f59e0b', heli: '#a855f7', rocket: '#0d9488', artillery: '#92400e', jet: '#0284c7', aa: '#db2777', mech: '#475569' };\nconst TYPE_HEX = [0x22b14c, 0xf59e0b, 0xa855f7, 0x0d9488, 0x92400e, 0x0284c7, 0xdb2777, 0x475569];\nconst ICON_PATHS = {\n  soldier: 'M12 3.5a3.6 3.6 0 1 1 0 7.2a3.6 3.6 0 1 1 0-7.2z M6.6 7.2h10.8a5.4 5.4 0 0 0-10.8 0z M5 22v-4.5a7 7 0 0 1 14 0V22z',\n  tank: 'M4 13h16a3.5 3.5 0 0 1 0 7H4a3.5 3.5 0 0 1 0-7z M7 8.5h9v4.5H7z M16 9.6h7.5v2.2H16z',\n  heli: 'M1.5 3h21v1.8h-21z M11 4.5h2v3.5h-2z M4 12.5a6 4.2 0 1 0 12 0a6 4.2 0 1 0-12 0z M15 11.3h7v2.2h-7z M20.8 8.5h2.2v5h-2.2z M4.5 18.6h11v1.6h-11z M7 15.5h1.6v3.4H7z M11.6 15.5h1.6v3.4h-1.6z',\n  rocket: 'M10 5.5a3.2 3.2 0 1 1 0 6.4a3.2 3.2 0 1 1 0-6.4z M3.5 22v-4.5a6.5 6.5 0 0 1 13 0V22z M2 6.6l17-3.6.6 2.6-17 3.6z M19 2.4l3.6-.8.8 3.6-3.6.8z',\n  artillery: 'M2 15h16a3 3 0 0 1 0 6H2a3 3 0 0 1 0-6z M5 10.5h9v4.5H5z M12 11.6l9.4-7.6 1.4 1.7-9.4 7.6z',\n  jet: 'M12 1l1.8 6.5 8.2 5.2v2.2l-8.2-2.4-.5 5.3 3 2.2V22L12 20.6 7.7 22v-2l3-2.2-.5-5.3L2 14.9v-2.2l8.2-5.2z',\n  aa: 'M1.5 16h15v4h-15z M16.5 17h5v3h-5z M6 12h7v4H6z M7.3 12.4l3.6-9.4 1.6.6-3.6 9.4z M10.3 12.4l3.6-9.4 1.6.6-3.6 9.4z M4 20.5a1.8 1.8 0 1 0 3.6 0z M14 20.5a1.8 1.8 0 1 0 3.6 0z',\n  mech: 'M8.5 1.5h7v5h-7z M5 7.5h14v8H5z M1.5 8.5H5v6H1.5z M19 8.5h3.5v6H19z M6.5 15.5h4v7h-4z M13.5 15.5h4v7h-4z'\n};\nfunction iconSVG(type, color) { return `<svg viewBox=\"0 0 24 24\" width=\"1em\" height=\"1em\"><path fill=\"${color}\" d=\"${ICON_PATHS[type]}\"/></svg>`; }\nfunction icBox(type) { return `<div class=\"ic\" style=\"background:${TYPE_COLOR[type]}\">${iconSVG(type, '#fff')}</div>`; }\nconst rand = (a, b) => a + Math.random() * (b - a);\nconst clamp = (v, a, b) => Math.max(a, Math.min(b, v));\nfunction fmt(n) { return n >= 10000 ? (n / 1000).toFixed(0) + 'k' : n >= 1000 ? (n / 1000).toFixed(1) + 'k' : '' + n; }\n\n// ============ globals ============\nlet cfg, renderer, scene, camera, sun, W = 720, H = 1280;\nlet initDone = false, appState = 'loading', time = 0, lastT = 0, endTimer = 0, introT = 0;\nlet levelGroup = null, levelDisposables = [], fortressObj = null, gateObjs = [];\nlet save = { level: 1, best: 0, wins: 0 };\nlet shake = 0;\nconst camPos = new THREE.Vector3(0, 40, 60), camLook = new THREE.Vector3(0, 0, -20);\n\nfunction ensureConfig() {\n  const g = window.gameConfig || (window.gameConfig = {});\n  for (const k in DEFAULT_CONFIG) if (g[k] === undefined) g[k] = JSON.parse(JSON.stringify(DEFAULT_CONFIG[k]));\n  g.units = g.units || {};\n  for (const t of UNIT_TYPES) g.units[t] = Object.assign({}, DEFAULT_CONFIG.units[t], g.units[t] || {});\n  cfg = g;\n}\nfunction lin(hex) { return new THREE.Color(hex).convertSRGBToLinear(); }\n\n// ============ geometry building (merged, vertex coloured) ============\nconst _pm = new THREE.Matrix4(), _pq = new THREE.Quaternion(), _pe = new THREE.Euler(), _pv = new THREE.Vector3(), _ps = new THREE.Vector3();\nfunction part(geo, hex, x, y, z, rx, ry, rz, sx, sy, sz) {\n  _pe.set(rx || 0, ry || 0, rz || 0); _pq.setFromEuler(_pe);\n  _pm.compose(_pv.set(x || 0, y || 0, z || 0), _pq, _ps.set(sx || 1, sy || 1, sz || 1));\n  return { g: geo, m: _pm.clone(), c: lin(hex) };\n}\nfunction merge(parts) {\n  let total = 0; const list = [];\n  for (const p of parts) { let g = p.g.index ? p.g.toNonIndexed() : p.g.clone(); g.applyMatrix4(p.m); list.push([g, p.c]); total += g.attributes.position.count; }\n  const pos = new Float32Array(total * 3), nor = new Float32Array(total * 3), col = new Float32Array(total * 3);\n  let o = 0;\n  for (const [g, c] of list) {\n    const n = g.attributes.position.count;\n    pos.set(g.attributes.position.array, o * 3); nor.set(g.attributes.normal.array, o * 3);\n    for (let v = 0; v < n; v++) { col[(o + v) * 3] = c.r; col[(o + v) * 3 + 1] = c.g; col[(o + v) * 3 + 2] = c.b; }\n    o += n; g.dispose();\n  }\n  const out = new THREE.BufferGeometry();\n  out.setAttribute('position', new THREE.BufferAttribute(pos, 3));\n  out.setAttribute('normal', new THREE.BufferAttribute(nor, 3));\n  out.setAttribute('color', new THREE.BufferAttribute(col, 3));\n  out.computeBoundingSphere();\n  return out;\n}\nconst BOX = new THREE.BoxGeometry(1, 1, 1);\nconst CYL8 = new THREE.CylinderGeometry(1, 1, 1, 8);\nconst CYL12 = new THREE.CylinderGeometry(1, 1, 1, 12);\nconst CONE8 = new THREE.CylinderGeometry(0.6, 1, 1, 8);\nconst SPH = new THREE.IcosahedronGeometry(1, 0);\nconst HEMI = new THREE.SphereGeometry(1, 8, 3, 0, Math.PI * 2, 0, Math.PI / 2);\nfunction B(hex, w, h, d, x, y, z, rx, ry, rz) { return part(BOX, hex, x, y, z, rx, ry, rz, w, h, d); }\nfunction C(hex, r, h, x, y, z, rx, ry, rz, geo) { return part(geo || CYL8, hex, x, y, z, rx, ry, rz, r, h, r); }\n\nconst PALETTES = {\n  blue: { main: 0x2f7df6, dark: 0x1c4fae, light: 0x7cb8ff, pack: 0x35507a },\n  red: { main: 0xe23b3b, dark: 0x99201f, light: 0xff8a7a, pack: 0x6e3a30 },\n  wreck: { main: 0x5d6168, dark: 0x45484e, light: 0x70747b, pack: 0x4a4d52 }\n};\nconst SKIN = 0xf1c29b, BOOT = 0x26292f, GUN = 0x24262b, METAL = 0x59606c, GLASS = 0x9fdcff, TRACK = 0x2e3239, WHEEL = 0x4b515b;\n\nfunction soldierBodyParts(p, pack) {\n  return [\n    B(p.dark, 0.38, 0.16, 0.24, 0, 0.62, 0), B(BOOT, 0.42, 0.07, 0.27, 0, 0.71, 0),\n    B(p.main, 0.42, 0.42, 0.26, 0, 0.94, 0), B(p.light, 0.3, 0.12, 0.27, 0, 1.08, -0.005),\n    B(pack || p.pack, 0.32, 0.34, 0.16, 0, 0.92, 0.2), B(p.dark, 0.34, 0.08, 0.18, 0, 1.11, 0.2),\n    part(SPH, SKIN, 0, 1.3, 0, 0, 0, 0, 0.15, 0.16, 0.15),\n    part(HEMI, p.dark, 0, 1.33, 0, 0, 0, 0, 0.19, 0.15, 0.2), B(p.dark, 0.36, 0.03, 0.42, 0, 1.33, -0.02)\n  ];\n}\nfunction soldierLegParts(p) { return [B(p.dark, 0.16, 0.42, 0.18, 0, -0.2, 0), B(BOOT, 0.18, 0.12, 0.27, 0, -0.5, -0.04)]; }\nfunction soldierArmParts(p) {\n  return [\n    B(p.main, 0.13, 0.13, 0.36, 0.23, -0.06, -0.14, 0.35), B(p.main, 0.13, 0.13, 0.36, -0.23, -0.06, -0.14, 0.35),\n    B(SKIN, 0.1, 0.1, 0.1, 0.12, -0.17, -0.33), B(SKIN, 0.1, 0.1, 0.1, -0.06, -0.15, -0.42),\n    B(GUN, 0.07, 0.11, 0.62, 0.06, -0.12, -0.42), B(0x6b4a2b, 0.07, 0.12, 0.2, 0.06, -0.16, -0.05),\n    B(GUN, 0.06, 0.15, 0.07, 0.06, -0.24, -0.45), B(GUN, 0.045, 0.045, 0.3, 0.06, -0.1, -0.87)\n  ];\n}\nfunction tankHullParts(p) {\n  const out = [\n    B(TRACK, 0.56, 0.62, 3.7, 0.98, 0.34, 0), B(TRACK, 0.56, 0.62, 3.7, -0.98, 0.34, 0),\n    B(p.dark, 0.66, 0.08, 3.8, 0.98, 0.69, 0), B(p.dark, 0.66, 0.08, 3.8, -0.98, 0.69, 0),\n    B(p.main, 1.5, 0.56, 3.2, 0, 0.72, 0.05), B(p.light, 1.5, 0.14, 0.8, 0, 0.86, -1.5, 0.45),\n    B(p.dark, 1.4, 0.3, 0.5, 0, 0.75, 1.65), B(METAL, 0.2, 0.18, 0.4, 0.45, 0.95, 1.55), B(METAL, 0.2, 0.18, 0.4, -0.45, 0.95, 1.55)\n  ];\n  for (let s of [-1, 1]) for (let w = 0; w < 4; w++) out.push(C(WHEEL, 0.27, 0.12, s * 1.27, 0.3, -1.2 + w * 0.8, 0, 0, Math.PI / 2));\n  return out;\n}\nfunction tankTurretParts(p) {\n  return [\n    B(p.main, 1.3, 0.5, 1.55, 0, 0.25, 0.1), B(p.light, 1.2, 0.32, 0.45, 0, 0.27, -0.82, 0.45),\n    B(p.dark, 0.9, 0.12, 1.1, 0, 0.55, 0.15), C(p.dark, 0.26, 0.14, 0.3, 0.64, 0.3, 0, 0, 0, CYL12),\n    C(GUN, 0.1, 1.9, 0, 0.27, -1.75, Math.PI / 2, 0, 0, CYL12), C(GUN, 0.15, 0.32, 0, 0.27, -2.75, Math.PI / 2, 0, 0, CYL12),\n    B(GUN, 0.08, 0.08, 0.5, -0.32, 0.72, -0.05), B(METAL, 0.03, 1.1, 0.03, -0.5, 1.0, 0.7)\n  ];\n}\nfunction heliBodyParts(p) {\n  return [\n    B(p.main, 0.92, 0.88, 2.0, 0, 0, 0), B(p.light, 0.82, 0.66, 0.65, 0, -0.05, -1.28),\n    B(GLASS, 0.74, 0.46, 0.95, 0, 0.32, -0.95, -0.2), B(p.dark, 0.7, 0.32, 1.1, 0, 0.58, 0.3),\n    C(p.dark, 0.17, 2.7, 0, 0.18, 2.25, Math.PI / 2, 0, 0, CONE8), B(p.main, 0.08, 0.85, 0.5, 0, 0.6, 3.5, -0.3),\n    B(p.dark, 1.1, 0.06, 0.32, 0, 0.22, 3.15), B(p.dark, 2.0, 0.08, 0.5, 0, -0.08, 0.15),\n    C(GUN, 0.15, 0.75, 0.9, -0.2, 0.05, Math.PI / 2), C(GUN, 0.15, 0.75, -0.9, -0.2, 0.05, Math.PI / 2),\n    B(METAL, 0.08, 0.08, 2.3, 0.55, -0.78, -0.1), B(METAL, 0.08, 0.08, 2.3, -0.55, -0.78, -0.1),\n    B(METAL, 0.06, 0.35, 0.06, 0.5, -0.6, -0.6), B(METAL, 0.06, 0.35, 0.06, -0.5, -0.6, -0.6),\n    B(METAL, 0.06, 0.35, 0.06, 0.5, -0.6, 0.5), B(METAL, 0.06, 0.35, 0.06, -0.5, -0.6, 0.5),\n    C(METAL, 0.09, 0.3, 0, 0.82, 0.1)\n  ];\n}\nfunction heliRotorParts() { return [B(0x3a3d42, 5.2, 0.04, 0.2, 0, 0, 0), B(0x3a3d42, 0.2, 0.04, 5.2, 0, 0, 0), C(METAL, 0.2, 0.14, 0, 0, 0)]; }\nfunction heliTailParts() { return [B(0x2a2a2a, 0.05, 1.3, 0.16, 0, 0, 0), B(0x2a2a2a, 0.05, 0.16, 1.3, 0, 0, 0)]; }\n\n\nfunction rocketArmParts(p) {\n  return [\n    B(p.main, 0.13, 0.13, 0.36, 0.21, 0.02, -0.12, -0.25), B(p.main, 0.13, 0.13, 0.36, -0.2, -0.06, -0.16, 0.35),\n    B(SKIN, 0.1, 0.1, 0.1, 0.2, 0.08, -0.32), B(SKIN, 0.1, 0.1, 0.1, -0.02, -0.12, -0.36),\n    C(0x55663a, 0.12, 1.25, 0.2, 0.16, -0.12, Math.PI / 2), C(GUN, 0.14, 0.1, 0.2, 0.16, 0.5, Math.PI / 2),\n    part(CONE8, 0x6b7d45, 0.2, 0.16, -0.86, -Math.PI / 2, 0, 0, 0.1, 0.25, 0.1), B(GUN, 0.06, 0.18, 0.08, 0.2, -0.02, -0.3)\n  ];\n}\nfunction artyHullParts(p) {\n  const out = [\n    B(TRACK, 0.56, 0.62, 4.0, 1.0, 0.34, 0), B(TRACK, 0.56, 0.62, 4.0, -1.0, 0.34, 0),\n    B(p.dark, 0.66, 0.08, 4.1, 1.0, 0.69, 0), B(p.dark, 0.66, 0.08, 4.1, -1.0, 0.69, 0),\n    B(p.main, 1.55, 0.5, 3.5, 0, 0.72, 0.1), B(p.light, 1.55, 0.12, 0.7, 0, 0.84, -1.65, 0.4),\n    B(METAL, 1.4, 0.15, 0.8, 0, 0.6, 2.0), B(METAL, 0.12, 0.5, 0.12, 0.6, 0.4, 2.3), B(METAL, 0.12, 0.5, 0.12, -0.6, 0.4, 2.3)\n  ];\n  for (let s of [-1, 1]) for (let w = 0; w < 5; w++) out.push(C(WHEEL, 0.26, 0.12, s * 1.3, 0.3, -1.5 + w * 0.75, 0, 0, Math.PI / 2));\n  return out;\n}\nfunction artyTurretParts(p) {\n  const a = 0.45, th = -(Math.PI / 2 - a);\n  return [\n    B(p.main, 1.5, 0.75, 1.9, 0, 0.38, 0.35), B(p.dark, 1.3, 0.12, 1.6, 0, 0.8, 0.4), B(p.light, 1.4, 0.4, 0.5, 0, 0.35, -0.7, 0.5),\n    C(GUN, 0.13, 3.3, 0, 0.45 + 1.65 * Math.sin(a), -0.5 - 1.65 * Math.cos(a), th, 0, 0, CYL12),\n    C(GUN, 0.19, 0.4, 0, 0.45 + 3.3 * Math.sin(a), -0.5 - 3.3 * Math.cos(a), th, 0, 0, CYL12),\n    B(METAL, 0.3, 0.3, 0.4, -0.55, 0.9, 0.9)\n  ];\n}\nfunction aaHullParts(p) {\n  const out = [\n    B(p.dark, 1.5, 0.35, 3.8, 0, 0.62, 0), B(p.main, 1.55, 0.95, 1.1, 0, 1.15, -1.3), B(GLASS, 1.4, 0.38, 0.1, 0, 1.35, -1.86),\n    B(p.main, 1.6, 0.3, 2.3, 0, 0.92, 0.6), B(METAL, 1.6, 0.12, 0.2, 0, 0.5, -1.95)\n  ];\n  for (let s of [-1, 1]) for (const z of [-1.3, 0.4, 1.3]) out.push(C(0x262a30, 0.42, 0.3, s * 0.82, 0.42, z, 0, 0, Math.PI / 2, CYL12));\n  return out;\n}\nfunction aaTurretParts(p) {\n  const out = [C(p.dark, 0.6, 0.3, 0, 0.15, 0, 0, 0, 0, CYL12), B(p.main, 1.0, 0.5, 0.9, 0, 0.5, 0)];\n  for (let s of [-1, 1]) {\n    out.push(B(p.light, 0.32, 0.45, 0.8, s * 0.68, 0.55, -0.05));\n    for (const d of [-0.07, 0.07]) out.push(C(GUN, 0.05, 1.4, s * 0.68 + d, 0.95, -0.55, -(Math.PI / 2 - 0.7), 0, 0));\n  }\n  out.push(C(METAL, 0.45, 0.06, 0, 1.05, 0.5, 0.5, 0, 0, CYL12));\n  return out;\n}\nfunction jetParts(p) {\n  return [\n    B(p.main, 0.72, 0.62, 3.6, 0, 0, 0), part(CONE8, p.light, 0, 0, -2.2, -Math.PI / 2, 0, 0, 0.36, 0.9, 0.32),\n    B(GLASS, 0.46, 0.34, 1.0, 0, 0.36, -0.95), B(p.dark, 3.8, 0.08, 1.5, 0, -0.05, 0.45), B(p.light, 3.0, 0.06, 0.5, 0, -0.03, -0.25),\n    B(p.dark, 0.08, 0.85, 0.75, 0.32, 0.5, 1.45, 0, 0, -0.28), B(p.dark, 0.08, 0.85, 0.75, -0.32, 0.5, 1.45, 0, 0, 0.28),\n    B(p.dark, 1.7, 0.06, 0.6, 0, 0.05, 1.6), C(0x30343a, 0.28, 0.4, 0, 0, 1.95, Math.PI / 2, 0, 0, CYL12),\n    B(GUN, 0.1, 0.1, 0.9, 1.2, -0.18, 0.4), B(GUN, 0.1, 0.1, 0.9, -1.2, -0.18, 0.4)\n  ];\n}\nfunction mechTorsoParts(p) {\n  const out = [\n    B(p.dark, 1.4, 0.5, 1.0, 0, 0, 0), B(p.main, 1.9, 1.3, 1.4, 0, 0.9, 0), B(p.light, 1.3, 0.6, 0.15, 0, 1.0, -0.72),\n    B(p.dark, 0.72, 0.5, 0.72, 0, 1.8, -0.1), B(0x7ff3ff, 0.55, 0.12, 0.05, 0, 1.85, -0.47),\n    B(p.dark, 0.7, 0.55, 0.95, 1.25, 1.35, 0), B(p.dark, 0.7, 0.55, 0.95, -1.25, 1.35, 0),\n    B(GUN, 0.45, 0.45, 1.6, 1.3, 0.65, -0.4), B(GUN, 0.45, 0.45, 1.6, -1.3, 0.65, -0.4),\n    C(METAL, 0.12, 0.6, 1.3, 0.65, -1.45, Math.PI / 2), C(METAL, 0.12, 0.6, -1.3, 0.65, -1.45, Math.PI / 2),\n    B(p.pack, 1.2, 0.8, 0.6, 0, 1.4, 0.95), B(0x2a2a2a, 0.9, 0.5, 0.05, 0, 1.6, 1.26)\n  ];\n  return out;\n}\nfunction mechLegParts(p) { return [B(p.dark, 0.5, 1.2, 0.6, 0, -0.6, 0), B(p.main, 0.58, 1.2, 0.66, 0, -1.7, 0.1), B(p.dark, 0.85, 0.25, 1.15, 0, -2.3, -0.1)]; }\n\n// ============ unit rendering (instanced) ============\nlet MAT_U;\nconst SETS = [null, null];   // SETS[team] = { m: [ {partName: InstancedMesh} per type ], n: [], caps: [] }\nconst CORPSE = {};\nconst RED_RENDER_CAPS = [1800, 180, 110, 660, 66, 88, 88, 36];\nconst MOUNT = { 1: [0, 1.0, 0.15], 4: [0, 1.0, 0.3], 6: [0, 1.05, 0.7] };\nconst INF_T = [0, 3], VEH_T = [1, 4, 6], AIR_T = [2, 5];\nfunction inst(geo, cap, shadow) {\n  const m = new THREE.InstancedMesh(geo, MAT_U, cap);\n  m.count = 0; m.frustumCulled = false; m.instanceMatrix.setUsage(THREE.DynamicDrawUsage);\n  m.castShadow = !!shadow; m.receiveShadow = false;\n  scene.add(m); return m;\n}\nconst shiftParts = (parts, x, y, z) => parts.map(q => { q.m.premultiply(new THREE.Matrix4().makeTranslation(x, y, z)); return q; });\nfunction buildUnitMeshes() {\n  MAT_U = new THREE.MeshLambertMaterial({ vertexColors: true });\n  [['blue', BLUE_CAPS, 0], ['red', RED_RENDER_CAPS, 1]].forEach(([name, caps, t]) => {\n    const p = PALETTES[name];\n    const leg = merge(soldierLegParts(p)), mleg = merge(mechLegParts(p));\n    const m = [];\n    m[0] = { body: inst(merge(soldierBodyParts(p)), caps[0], true), legL: inst(leg, caps[0], true), legR: inst(leg, caps[0], true), arms: inst(merge(soldierArmParts(p)), caps[0], true) };\n    m[3] = { body: inst(merge(soldierBodyParts(p, 0x55663a)), caps[3], true), legL: inst(leg, caps[3], true), legR: inst(leg, caps[3], true), arms: inst(merge(rocketArmParts(p)), caps[3], true) };\n    m[1] = { hull: inst(merge(tankHullParts(p)), caps[1], true), turret: inst(merge(tankTurretParts(p)), caps[1], true) };\n    m[4] = { hull: inst(merge(artyHullParts(p)), caps[4], true), turret: inst(merge(artyTurretParts(p)), caps[4], true) };\n    m[6] = { hull: inst(merge(aaHullParts(p)), caps[6], true), turret: inst(merge(aaTurretParts(p)), caps[6], true) };\n    m[2] = { body: inst(merge(heliBodyParts(p)), caps[2], true), rotor: inst(merge(heliRotorParts()), caps[2], false), tail: inst(merge(heliTailParts()), caps[2], false) };\n    m[5] = { body: inst(merge(jetParts(p)), caps[5], true) };\n    m[7] = { body: inst(merge(mechTorsoParts(p)), caps[7], true), legL: inst(mleg, caps[7], true), legR: inst(mleg, caps[7], true) };\n    SETS[t] = { m, n: new Array(NT).fill(0), caps };\n    const cp = soldierBodyParts(p).concat(shiftParts(soldierLegParts(p), 0.1, 0.62, 0), shiftParts(soldierLegParts(p), -0.1, 0.62, 0), shiftParts(soldierArmParts(p), 0, 1.0, 0));\n    CORPSE['s' + t] = inst(merge(cp), 1400, false);\n  });\n  const w = PALETTES.wreck;\n  CORPSE[1] = inst(merge(tankHullParts(w).concat(shiftParts(tankTurretParts(w), 0, 1.0, 0.15))), 160, true);\n  CORPSE[4] = inst(merge(artyHullParts(w).concat(shiftParts(artyTurretParts(w), 0, 1.0, 0.3))), 80, true);\n  CORPSE[6] = inst(merge(aaHullParts(w).concat(shiftParts(aaTurretParts(w), 0, 1.05, 0.7))), 80, true);\n  CORPSE[7] = inst(merge(shiftParts(mechTorsoParts(w), 0, 2.4, 0).concat(shiftParts(mechLegParts(w), 0.55, 2.4, 0), shiftParts(mechLegParts(w), -0.55, 2.4, 0))), 40, true);\n  CORPSE[2] = inst(merge(heliBodyParts(w)), 120, true);\n  CORPSE[5] = inst(merge(jetParts(w)), 100, true);\n}\n\n// fast matrix writes: rotation = Ry(yaw)\u00b7Rx(pitch)\u00b7Rz(roll)\nconst RB = new Float64Array(9), RP = new Float64Array(9), RF = new Float64Array(9);\nfunction rotYXZ(out, yaw, pitch, roll) {\n  const a = Math.cos(pitch), b = Math.sin(pitch), c = Math.cos(yaw), d = Math.sin(yaw), e = Math.cos(roll), f = Math.sin(roll);\n  const ce = c * e, cf = c * f, de = d * e, df = d * f;\n  out[0] = ce + df * b; out[1] = de * b - cf; out[2] = a * d;\n  out[3] = a * f; out[4] = a * e; out[5] = -b;\n  out[6] = cf * b - de; out[7] = df + ce * b; out[8] = a * c;\n}\nfunction rotAxis(out, axis, ang) {\n  const c = Math.cos(ang), s = Math.sin(ang);\n  if (axis === 0) { out[0] = 1; out[1] = 0; out[2] = 0; out[3] = 0; out[4] = c; out[5] = -s; out[6] = 0; out[7] = s; out[8] = c; }\n  else if (axis === 1) { out[0] = c; out[1] = 0; out[2] = s; out[3] = 0; out[4] = 1; out[5] = 0; out[6] = -s; out[7] = 0; out[8] = c; }\n  else { out[0] = c; out[1] = -s; out[2] = 0; out[3] = s; out[4] = c; out[5] = 0; out[6] = 0; out[7] = 0; out[8] = 1; }\n}\nfunction mul3(o, a, b) {\n  for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) o[r * 3 + c] = a[r * 3] * b[c] + a[r * 3 + 1] * b[3 + c] + a[r * 3 + 2] * b[6 + c];\n}\nfunction writeM(arr, idx, R, sx, sy, sz, tx, ty, tz) {\n  const o = idx * 16;\n  arr[o] = R[0] * sx; arr[o + 1] = R[3] * sx; arr[o + 2] = R[6] * sx; arr[o + 3] = 0;\n  arr[o + 4] = R[1] * sy; arr[o + 5] = R[4] * sy; arr[o + 6] = R[7] * sy; arr[o + 7] = 0;\n  arr[o + 8] = R[2] * sz; arr[o + 9] = R[5] * sz; arr[o + 10] = R[8] * sz; arr[o + 11] = 0;\n  arr[o + 12] = tx; arr[o + 13] = ty; arr[o + 14] = tz; arr[o + 15] = 1;\n}\nfunction writePart(arr, idx, R, s, bx, by, bz, lx, ly, lz, axis, ang) {\n  const tx = bx + s * (R[0] * lx + R[1] * ly + R[2] * lz), ty = by + s * (R[3] * lx + R[4] * ly + R[5] * lz), tz = bz + s * (R[6] * lx + R[7] * ly + R[8] * lz);\n  if (axis < 0) { writeM(arr, idx, R, s, s, s, tx, ty, tz); return; }\n  rotAxis(RP, axis, ang); mul3(RF, R, RP); writeM(arr, idx, RF, s, s, s, tx, ty, tz);\n}\n\nconst VIS_SCALE = [1.3, 1.12, 1.0, 1.3, 1.1, 0.9, 1.1, 1.0];\nconst legPh = new Float32Array(MAXU), hullYaw = new Float32Array(MAXU), hPitch = new Float32Array(MAXU), hRoll = new Float32Array(MAXU), spawnAt = new Float32Array(MAXU);\nfunction onUnitSpawn(i) { legPh[i] = Math.random() * 6.28; hullYaw[i] = U.yaw[i]; hPitch[i] = 0; hRoll[i] = 0; spawnAt[i] = time; }\nfunction airAlt(i) { const k = U.type[i]; return UNIT_ALT[k] + Math.sin(time * (k === 5 ? 1.2 : 1.8) + U.ph[i]) * (k === 5 ? 0.6 : 0.25); }\nfunction jetWeave(i) { return Math.sin(time * 0.9 + U.ph[i] * 3) * 2.2; }\n\nfunction renderUnits(dt) {\n  for (const t of [0, 1]) SETS[t].n.fill(0);\n  for (let i = 0; i < U.hi; i++) {\n    if (!U.alive[i]) continue;\n    const team = U.team[i], k = U.type[i], S = SETS[team];\n    const idx = S.n[k]; if (idx >= S.caps[k]) continue; S.n[k]++;\n    const m = S.m[k];\n    let x = U.x[i], z = U.z[i];\n    const spd = U.spd[i];\n    const age = time - spawnAt[i], sc = (age < 0.3 ? 0.35 + age / 0.3 * 0.65 : 1) * VIS_SCALE[k];\n    if (k === 0 || k === 3) {\n      const run = Math.min(1, spd / 7), eng = U.engaged[i];\n      legPh[i] += dt * (3 + spd * 1.15);\n      const sw = Math.sin(legPh[i]) * 0.8 * run;\n      const bob = Math.abs(Math.cos(legPh[i])) * 0.08 * run;\n      const rt = k === 3 ? 0.25 : 0.09;\n      const rec = U.fireT[i] < rt ? (1 - U.fireT[i] / rt) * (k === 3 ? 0.4 : 0.3) : 0;\n      const arm = eng ? -0.05 + rec : Math.sin(legPh[i]) * 0.12 * run - 0.08;\n      rotYXZ(RB, U.yaw[i], eng ? 0.04 : -0.14 * run, 0);\n      writeM(m.body.instanceMatrix.array, idx, RB, sc, sc, sc, x, bob, z);\n      writePart(m.legL.instanceMatrix.array, idx, RB, sc, x, bob, z, 0.1, 0.62, 0, 0, sw);\n      writePart(m.legR.instanceMatrix.array, idx, RB, sc, x, bob, z, -0.1, 0.62, 0, 0, -sw);\n      writePart(m.arms.instanceMatrix.array, idx, RB, sc, x, bob, z, 0, 1.0, 0, 0, arm);\n    } else if (k === 1 || k === 4 || k === 6) {\n      if (spd > 1.2) { const want = Math.atan2(-U.vx[i], -U.vz[i]); let d = want - hullYaw[i]; d = Math.atan2(Math.sin(d), Math.cos(d)); hullYaw[i] += d * Math.min(1, dt * 3); }\n      const run = Math.min(1, spd / 8);\n      rotYXZ(RB, hullYaw[i], Math.sin(time * 16 + U.ph[i]) * 0.012 * run, 0);\n      writeM(m.hull.instanceMatrix.array, idx, RB, sc, sc, sc, x, 0, z);\n      const rt = k === 4 ? 0.5 : k === 6 ? 0.1 : 0.35, ra = k === 4 ? 0.7 : k === 6 ? 0.08 : 0.45;\n      const rec = U.fireT[i] < rt ? (1 - U.fireT[i] / rt) * ra : 0;\n      const mo = MOUNT[k];\n      const mx = x + sc * (RB[0] * mo[0] + RB[1] * mo[1] + RB[2] * mo[2]), my = sc * (RB[3] * mo[0] + RB[4] * mo[1] + RB[5] * mo[2]), mz = z + sc * (RB[6] * mo[0] + RB[7] * mo[1] + RB[8] * mo[2]);\n      rotYXZ(RF, U.yaw[i], 0, 0);\n      writeM(m.turret.instanceMatrix.array, idx, RF, sc, sc, sc, mx + RF[2] * rec * sc, my, mz + RF[8] * rec * sc);\n      if (spd > 3 && Math.random() < dt * 6) emitDust(x + Math.sin(hullYaw[i]) * 1.9, z + Math.cos(hullYaw[i]) * 1.9);\n    } else if (k === 7) {\n      const run = Math.min(1, spd / 6);\n      legPh[i] += dt * (1.4 + spd * 0.45);\n      const sw = Math.sin(legPh[i]) * 0.45 * run;\n      const bob = Math.abs(Math.cos(legPh[i])) * 0.2 * run;\n      if (run > 0.3 && Math.abs(Math.cos(legPh[i])) < 0.08 && Math.random() < 0.5) emitDust(x + rand(-1, 1), z + rand(-0.5, 0.5));\n      const rec = U.fireT[i] < 0.2 ? (1 - U.fireT[i] / 0.2) * 0.12 : 0;\n      rotYXZ(RB, U.yaw[i], -rec, Math.sin(legPh[i]) * 0.04 * run);\n      writePart(m.body.instanceMatrix.array, idx, RB, sc, x, bob, z, 0, 2.4, 0, -1, 0);\n      rotYXZ(RF, U.yaw[i], 0, 0);\n      writePart(m.legL.instanceMatrix.array, idx, RF, sc, x, bob, z, 0.55, 2.4, 0, 0, sw);\n      writePart(m.legR.instanceMatrix.array, idx, RF, sc, x, bob, z, -0.55, 2.4, 0, 0, -sw);\n    } else {\n      const y = U.yaw[i], fx = -Math.sin(y), fz = -Math.cos(y), rx = Math.cos(y), rz = -Math.sin(y);\n      const fs = U.vx[i] * fx + U.vz[i] * fz;\n      let ls = U.vx[i] * rx + U.vz[i] * rz;\n      if (k === 5) { const wv = jetWeave(i); x += rx * wv; z += rz * wv; ls += Math.cos(time * 0.9 + U.ph[i] * 3) * 2.2 * 0.9 * 6; }\n      const wp = -clamp(fs / 20, -1, 1) * (k === 5 ? 0.12 : 0.3) + (U.engaged[i] && k === 2 ? -0.12 : 0), wr = -clamp(ls / 14, -1, 1) * (k === 5 ? 0.9 : 0.45);\n      hPitch[i] += (wp - hPitch[i]) * Math.min(1, dt * 3); hRoll[i] += (wr - hRoll[i]) * Math.min(1, dt * 3);\n      const alt = airAlt(i);\n      rotYXZ(RB, y, hPitch[i], hRoll[i]);\n      writeM(m.body.instanceMatrix.array, idx, RB, sc, sc, sc, x, alt, z);\n      if (k === 2) {\n        writePart(m.rotor.instanceMatrix.array, idx, RB, sc, x, alt, z, 0, 0.9, 0.1, 1, time * 32 + U.ph[i]);\n        writePart(m.tail.instanceMatrix.array, idx, RB, sc, x, alt, z, 0.16, 0.62, 3.55, 0, time * 45);\n      } else if (Math.random() < dt * 14) emitP(FXA, x - fx * 2.4 * sc, alt, z - fz * 2.4 * sc, 0, 0, 0, 0.12, 0.35, 0xffa040, 0);\n    }\n  }\n  for (const t of [0, 1]) {\n    const S = SETS[t];\n    for (let k = 0; k < NT; k++) for (const name in S.m[k]) { const mm = S.m[k][name]; mm.count = S.n[k]; mm.visible = S.n[k] > 0; mm.instanceMatrix.needsUpdate = true; }\n  }\n}\n\n// ============ corpses & wrecks ============\nconst CCAP = 2600;\nconst corpses = [];\nlet cIdx = 0;\nfor (let i = 0; i < CCAP; i++) corpses.push({ on: false });\nfunction addCorpse(i) {\n  const c = corpses[cIdx]; cIdx = (cIdx + 1) % CCAP;\n  const k = U.type[i];\n  c.on = true; c.k = k; c.team = U.team[i]; c.x = U.x[i]; c.z = U.z[i]; c.t = 0;\n  c.yaw = VEH_T.includes(k) ? hullYaw[i] : U.yaw[i]; c.vx = U.vx[i] * 0.3 + rand(-1, 1); c.vz = U.vz[i] * 0.3 + rand(-1, 1);\n  c.side = Math.random() < 0.5 ? -1 : 1;\n  c.sc = VIS_SCALE[k];\n  if (AIR_T.includes(k)) { c.y = UNIT_ALT[k]; c.vy = 0; c.spin = rand(3, 6) * c.side; c.landed = false; if (k === 5) { c.vx += -Math.sin(U.yaw[i]) * 14; c.vz += -Math.cos(U.yaw[i]) * 14; } }\n}\nfunction renderCorpses(dt) {\n  const cs = [CORPSE.s0, CORPSE.s1], n = [0, 0], nv = {};\n  for (const k of [1, 2, 4, 5, 6, 7]) nv[k] = 0;\n  for (const c of corpses) {\n    if (!c.on) continue;\n    c.t += dt;\n    const k = c.k;\n    if (k === 0 || k === 3) {\n      if (c.t > 2.6) { c.on = false; continue; }\n      const f = Math.min(1, c.t / 0.35);\n      c.x += c.vx * dt * (1 - f); c.z += c.vz * dt * (1 - f);\n      const sink = c.t > 1.8 ? (c.t - 1.8) * 0.9 : 0;\n      rotYXZ(RB, c.yaw, f * Math.PI / 2 * 0.95, f * 0.3 * c.side);\n      const m = cs[c.team]; if (n[c.team] >= 1400) continue;\n      writeM(m.instanceMatrix.array, n[c.team]++, RB, 1.3, 1.3, 1.3, c.x, 0.05 - sink, c.z);\n    } else if (k === 2 || k === 5) {\n      if (c.t > 5) { c.on = false; continue; }\n      if (!c.landed) {\n        c.vy -= 14 * dt; c.y += c.vy * dt; c.yaw += c.spin * dt * (k === 5 ? 0.3 : 1); c.x += c.vx * dt; c.z += c.vz * dt;\n        emitSmoke(c.x, c.y, c.z, 0.7);\n        if (c.y <= 0.75) { c.y = 0.75; c.landed = true; explosion(c.x, 1, c.z, k === 5 ? 1.9 : 1.6); sfx('sfx_explosion', 0.5, rand(0.85, 1.1), 0.08); shake = Math.min(1.2, shake + 0.3); }\n      } else if (c.t < 3.5 && Math.random() < dt * 4) emitSmoke(c.x, 1.2, c.z, 1);\n      const sink = c.t > 4 ? (c.t - 4) * 1.2 : 0;\n      rotYXZ(RB, c.yaw, c.landed ? 0.15 : (k === 5 ? -0.5 : -0.3), c.landed ? 0.35 * c.side : (k === 5 ? c.t * 3 * c.side : 0.2 * c.side));\n      if (nv[k] < CORPSE[k].instanceMatrix.count) writeM(CORPSE[k].instanceMatrix.array, nv[k]++, RB, c.sc, c.sc, c.sc, c.x, c.y - sink, c.z);\n    } else {\n      const life = k === 7 ? 4.4 : 3.6;\n      if (c.t > life) { c.on = false; continue; }\n      if (c.t < life - 1 && Math.random() < dt * 5) emitSmoke(c.x + rand(-0.6, 0.6), k === 7 ? 1.6 : 1.4, c.z + rand(-0.6, 0.6), k === 7 ? 1.6 : 1.2);\n      if (c.t < 2 && Math.random() < dt * 6) emitFire(c.x + rand(-0.6, 0.6), 1.1, c.z + rand(-0.8, 0.8));\n      const sink = c.t > life - 0.8 ? (c.t - life + 0.8) * 1.6 : 0;\n      const topple = k === 7 ? Math.min(1, c.t / 0.7) * 1.45 : 0.04;\n      if (k === 7 && c.t > 0.7 && !c.boomed) { c.boomed = true; explosion(c.x - Math.sin(c.yaw) * 3, 1, c.z - Math.cos(c.yaw) * 3, 1.8); shake = Math.min(1.4, shake + 0.4); }\n      rotYXZ(RB, c.yaw, -topple, 0.06 * c.side);\n      if (nv[k] < CORPSE[k].instanceMatrix.count) writeM(CORPSE[k].instanceMatrix.array, nv[k]++, RB, c.sc, c.sc, c.sc, c.x, -sink, c.z);\n    }\n  }\n  cs[0].count = n[0]; cs[1].count = n[1];\n  for (const k of [1, 2, 4, 5, 6, 7]) CORPSE[k].count = nv[k];\n  for (const m of [cs[0], cs[1]].concat([1, 2, 4, 5, 6, 7].map(k => CORPSE[k]))) { m.visible = m.count > 0; m.instanceMatrix.needsUpdate = true; }\n}\n\n// ============ particles & projectiles ============\nfunction makePool(cap, additive) {\n  const mat = additive ? new THREE.MeshBasicMaterial({ color: 0xffffff, blending: THREE.AdditiveBlending, transparent: true, depthWrite: false })\n    : new THREE.MeshBasicMaterial({ color: 0xffffff });\n  const mesh = new THREE.InstancedMesh(BOX, mat, cap);\n  mesh.frustumCulled = false;\n  const c = new THREE.Color(1, 1, 1);\n  for (let i = 0; i < cap; i++) mesh.setColorAt(i, c);\n  mesh.count = 0;\n  scene.add(mesh);\n  return { cap, mesh, add: additive, i: 0, x: new Float32Array(cap), y: new Float32Array(cap), z: new Float32Array(cap), vx: new Float32Array(cap), vy: new Float32Array(cap), vz: new Float32Array(cap),\n    life: new Float32Array(cap), max: new Float32Array(cap), size: new Float32Array(cap), grow: new Float32Array(cap), r: new Float32Array(cap), g: new Float32Array(cap), b: new Float32Array(cap), grav: new Float32Array(cap), rot: new Float32Array(cap) };\n}\nlet FXA, FXS;\nconst _col = new THREE.Color();\nfunction emitP(P, x, y, z, vx, vy, vz, life, size, hex, grav, grow) {\n  const i = P.i; P.i = (P.i + 1) % P.cap;\n  P.x[i] = x; P.y[i] = y; P.z[i] = z; P.vx[i] = vx; P.vy[i] = vy; P.vz[i] = vz; P.life[i] = P.max[i] = life; P.size[i] = size; P.grav[i] = grav; P.grow[i] = grow || 0; P.rot[i] = Math.random() * 6;\n  _col.setHex(hex).convertSRGBToLinear(); P.r[i] = _col.r; P.g[i] = _col.g; P.b[i] = _col.b;\n}\nfunction updatePool(P, dt) {\n  const arr = P.mesh.instanceMatrix.array, carr = P.mesh.instanceColor.array;\n  let n = 0;\n  for (let i = 0; i < P.cap; i++) {\n    if (P.life[i] <= 0) continue;\n    P.life[i] -= dt; if (P.life[i] <= 0) continue;\n    P.vy[i] -= 18 * P.grav[i] * dt; P.x[i] += P.vx[i] * dt; P.y[i] += P.vy[i] * dt; P.z[i] += P.vz[i] * dt;\n    if (P.y[i] < 0.05) { P.y[i] = 0.05; P.vy[i] *= -0.25; P.vx[i] *= 0.5; P.vz[i] *= 0.5; }\n    const f = P.life[i] / P.max[i];\n    const s = P.size[i] * (P.grow[i] ? (1 + (1 - f) * P.grow[i]) : (0.25 + 0.75 * f));\n    rotYXZ(RB, P.rot[i] + P.life[i] * 3, P.rot[i], 0);\n    writeM(arr, n, RB, s, s, s, P.x[i], P.y[i], P.z[i]);\n    const k = P.add ? f : 1;\n    carr[n * 3] = P.r[i] * k; carr[n * 3 + 1] = P.g[i] * k; carr[n * 3 + 2] = P.b[i] * k;\n    n++;\n  }\n  P.mesh.count = n; P.mesh.instanceMatrix.needsUpdate = true; P.mesh.instanceColor.needsUpdate = true;\n}\nconst FIRE = [0xfff176, 0xffc107, 0xff8f00, 0xff5722];\nfunction explosion(x, y, z, s) {\n  s = s || 1;\n  emitP(FXA, x, y, z, 0, 0, 0, 0.1, 0.9 * s, 0xffc46b, 0, 0.8);\n  for (let i = 0; i < 12; i++) emitP(FXA, x, y, z, rand(-6, 6) * s, rand(2, 8) * s, rand(-6, 6) * s, rand(0.25, 0.55), rand(0.4, 0.9) * s, FIRE[i % 4], 0.2);\n  for (let i = 0; i < 7; i++) emitP(FXS, x + rand(-.6, .6), y, z + rand(-.6, .6), rand(-1.5, 1.5), rand(1.5, 4), rand(-1.5, 1.5), rand(0.8, 1.4), rand(0.5, 0.9) * s, i % 2 ? 0x9da1a8 : 0xc4c7cc, -0.05, 1.2);\n  for (let i = 0; i < 6; i++) emitP(FXS, x, y, z, rand(-8, 8) * s, rand(5, 11) * s, rand(-8, 8) * s, rand(0.7, 1.1), rand(0.1, 0.22) * s, 0x5a524a, 1);\n}\nfunction emitSmoke(x, y, z, s) { emitP(FXS, x, y, z, rand(-0.4, 0.4), rand(1.5, 2.6), rand(-0.4, 0.4), rand(0.8, 1.3), rand(0.3, 0.5) * s, Math.random() < 0.5 ? 0x8d9198 : 0xb9bcc2, -0.05, 1.3); }\nfunction emitFire(x, y, z) { emitP(FXA, x, y, z, rand(-0.3, 0.3), rand(1.5, 3), rand(-0.3, 0.3), rand(0.25, 0.45), rand(0.3, 0.55), FIRE[(Math.random() * 4) | 0], -0.05); }\nfunction emitDust(x, z) { emitP(FXS, x + rand(-0.6, 0.6), 0.2, z + rand(-0.4, 0.4), rand(-0.5, 0.5), rand(0.4, 1), rand(-0.5, 0.5), rand(0.6, 1), rand(0.25, 0.45), 0xddd0ae, -0.01, 1.1); }\nfunction puff(x, y, z, team) {\n  const c = team === 0 ? 0x7cb8ff : 0xff8a7a;\n  for (let i = 0; i < 3; i++) emitP(FXS, x, y, z, rand(-2.5, 2.5), rand(2, 4), rand(-2.5, 2.5), rand(0.35, 0.6), rand(0.1, 0.18), i ? 0xdddddd : c, 1);\n}\n// projectiles\nconst PRCAP = 1100;\nconst PR = { mesh: null, n: 0, on: new Uint8Array(PRCAP), sx: new Float32Array(PRCAP), sy: new Float32Array(PRCAP), sz: new Float32Array(PRCAP), tx: new Float32Array(PRCAP), ty: new Float32Array(PRCAP), tz: new Float32Array(PRCAP), t: new Float32Array(PRCAP), dur: new Float32Array(PRCAP), k: new Uint8Array(PRCAP), team: new Uint8Array(PRCAP), i: 0 };\nfunction initProjectiles() {\n  PR.mesh = new THREE.InstancedMesh(BOX, new THREE.MeshBasicMaterial({ color: 0xffffff, blending: THREE.AdditiveBlending, transparent: true, depthWrite: false }), PRCAP);\n  PR.mesh.frustumCulled = false;\n  for (let i = 0; i < PRCAP; i++) PR.mesh.setColorAt(i, _col.setHex(0xffffff));\n  PR.mesh.count = 0;\n  scene.add(PR.mesh);\n}\n// per-type projectile look: [width, length, colour, arc height, smoke trail]\nconst PSTYLE = [[0.07, 1.1, 0xfff59d, 0, 0], [0.24, 0.9, 0xffb74d, 1.2, 0], [0.16, 0.8, 0xff7043, 0, 1], [0.18, 0.7, 0xffab40, 0.3, 1],\n  [0.3, 0.6, 0xffcc80, 7, 0], [0.14, 1.0, 0xff8a65, 0, 1], [0.06, 1.6, 0xfffde7, 0, 0], [0.3, 1.3, 0x80deea, 0.4, 0]];\nconst PCOLS = PSTYLE.map(p => new THREE.Color(p[2]).convertSRGBToLinear());\nfunction targetHeight(j) { const k = U.type[j]; return UNIT_LAYER[k] ? UNIT_ALT[k] : k === 7 ? 2.8 : (k === 0 || k === 3) ? 0.8 : 1.0; }\nfunction unitMuzzle(i, out) {\n  const k = U.type[i], y = U.yaw[i], s = Math.sin(y), c = Math.cos(y), x = U.x[i], z = U.z[i];\n  const fwd = (d, side, h) => { out[0] = x - s * d + c * side; out[1] = h; out[2] = z - c * d - s * side; };\n  switch (k) {\n    case 0: fwd(0.9, 0.06, 1.0); break;\n    case 3: fwd(1.1, 0.26, 1.4); break;\n    case 1: fwd(3.0, 0, 1.3); break;\n    case 4: fwd(3.7, 0, 2.6); break;\n    case 6: fwd(1.6, Math.random() < 0.5 ? -0.75 : 0.75, 2.4); break;\n    case 7: fwd(1.9, Math.random() < 0.5 ? -1.3 : 1.3, 3.0); break;\n    case 2: fwd(0.6, Math.random() < 0.5 ? -0.9 : 0.9, UNIT_ALT[2] - 0.2); break;\n    default: { const wv = jetWeave(i); out[0] = x + c * wv - s * 1.4; out[1] = UNIT_ALT[5] - 0.2; out[2] = z - s * wv - c * 1.4; }\n  }\n}\nconst _mz = [0, 0, 0];\nlet shotBudget = 0;\nfunction onShot(i, j, flight) {\n  const k = U.type[i];\n  if ((k === 0 || k === 6) && shotBudget <= 0) return;\n  if (k === 0 || k === 6) shotBudget--;\n  unitMuzzle(i, _mz);\n  const p = PR.i; PR.i = (PR.i + 1) % PRCAP;\n  PR.on[p] = 1; PR.sx[p] = _mz[0]; PR.sy[p] = _mz[1]; PR.sz[p] = _mz[2];\n  PR.tx[p] = U.x[j] + rand(-0.3, 0.3); PR.ty[p] = targetHeight(j); PR.tz[p] = U.z[j] + rand(-0.3, 0.3);\n  PR.t[p] = 0; PR.dur[p] = Math.max(0.05, flight); PR.k[p] = k; PR.team[p] = U.team[i];\n  if (k === 0 || k === 6) emitP(FXA, _mz[0], _mz[1], _mz[2], 0, 0, 0, 0.06, 0.32, 0xfff3c4, 0);\n  else if (k === 1 || k === 4 || k === 7) {\n    emitP(FXA, _mz[0], _mz[1], _mz[2], 0, 0, 0, 0.12, k === 4 ? 1.8 : 1.4, k === 7 ? 0xb2ebf2 : 0xffe0a0, 0); emitSmoke(_mz[0], _mz[1], _mz[2], k === 4 ? 1.3 : 0.9);\n    if (U.team[i] === 0) shake = Math.min(1, shake + (k === 4 ? 0.07 : 0.04));\n  } else { emitP(FXA, _mz[0], _mz[1], _mz[2], 0, 0, 0, 0.1, 0.6, 0xffcc80, 0); emitSmoke(_mz[0], _mz[1], _mz[2], 0.5); }\n}\nfunction updateProjectiles(dt) {\n  const arr = PR.mesh.instanceMatrix.array, carr = PR.mesh.instanceColor.array;\n  let n = 0;\n  for (let p = 0; p < PRCAP; p++) {\n    if (!PR.on[p]) continue;\n    PR.t[p] += dt / PR.dur[p];\n    if (PR.t[p] >= 1) { PR.on[p] = 0; continue; }\n    const t = PR.t[p], k = PR.k[p], st = PSTYLE[k];\n    const dx = PR.tx[p] - PR.sx[p], dy = PR.ty[p] - PR.sy[p], dz = PR.tz[p] - PR.sz[p];\n    const arc = st[3] * 4 * t * (1 - t), darc = st[3] * 4 * (1 - 2 * t);\n    const x = PR.sx[p] + dx * t, z = PR.sz[p] + dz * t, y = PR.sy[p] + dy * t + arc;\n    if (st[4] && Math.random() < 0.5) emitP(FXS, x, y, z, 0, 0.5, 0, 0.4, 0.22, 0xd0d0d0, -0.02, 1.5);\n    const yaw = Math.atan2(-dx, -dz), pitch = Math.atan2(dy + darc, Math.sqrt(dx * dx + dz * dz));\n    rotYXZ(RB, yaw, pitch, 0);\n    writeM(arr, n, RB, st[0], st[0], st[1], x, y, z);\n    const c = PCOLS[k];\n    carr[n * 3] = c.r * 2; carr[n * 3 + 1] = c.g * 2; carr[n * 3 + 2] = c.b * 2;\n    n++;\n  }\n  PR.mesh.count = n; PR.mesh.instanceMatrix.needsUpdate = true; PR.mesh.instanceColor.needsUpdate = true;\n}\nlet impactBudget = 0;\nfunction onImpact(k, x, z, vt) {\n  const y = UNIT_LAYER[vt] ? UNIT_ALT[vt] : vt === 7 ? 2.6 : (vt === 0 || vt === 3) ? 0.6 : 1.0;\n  if (k === 4) { explosion(x, 0.6, z, 1.25); emitDust(x, z); emitDust(x, z); sfx('sfx_explosion', 0.3, rand(0.8, 1.0), 0.06); }\n  else if (k === 1 || k === 7) { explosion(x, y, z, k === 7 ? 0.8 : 0.7); emitDust(x, z); sfx('sfx_explosion', 0.25, rand(1.1, 1.4), 0.07); }\n  else if (k === 2 || k === 3 || k === 5) explosion(x, y, z, 0.45);\n  else if (impactBudget-- > 0) {\n    if (k === 6 && UNIT_LAYER[vt]) emitP(FXS, x + rand(-1, 1), y + rand(-0.5, 1), z + rand(-1, 1), 0, 0.3, 0, 0.5, 0.35, 0x3d3f45, -0.01, 1.2);\n    else emitP(FXA, x, y, z, rand(-2, 2), rand(1, 3), rand(-2, 2), 0.15, 0.12, 0xfff59d, 0.5);\n  }\n}\nlet killFx = 0;\nfunction onKill(i) {\n  addCorpse(i);\n  const k = U.type[i];\n  if (k === 0 || k === 3) { if (killFx-- > 0) puff(U.x[i], 0.7, U.z[i], U.team[i]); }\n  else if (UNIT_LAYER[k]) explosion(U.x[i], UNIT_ALT[k], U.z[i], 0.9);\n  else {\n    const big = k === 7 ? 2.4 : 1.5;\n    explosion(U.x[i], k === 7 ? 3 : 1.2, U.z[i], big);\n    for (let q = 0; q < 4; q++) emitP(FXS, U.x[i], 1.3, U.z[i], rand(-4, 4), rand(8, 13), rand(-4, 4), 1.2, 0.35, 0x4a4f58, 1);\n    sfx('sfx_explosion', k === 7 ? 0.8 : 0.55, rand(0.85, 1.1) * (k === 7 ? 0.8 : 1), 0.06); shake = Math.min(1.2, shake + (k === 7 ? 0.5 : 0.2));\n  }\n}\n\n// ============ audio ============\nconst audio = { ctx: null, master: null, buffers: {}, raw: {}, music: null, muted: false, wantMusic: false, last: {} };\nfunction initAudio() {\n  for (const id of ['music_battle', 'sfx_gunfire', 'sfx_explosion', 'sfx_gate', 'sfx_victory']) {\n    const a = lib.getAsset(id);\n    if (a && a.url) audio.raw[id] = fetch(a.url).then(r => r.arrayBuffer()).catch(() => null);\n  }\n  const unlock = () => {\n    if (!audio.ctx) {\n      try {\n        audio.ctx = new (window.AudioContext || window.webkitAudioContext)();\n        audio.master = audio.ctx.createGain(); audio.master.gain.value = audio.muted ? 0 : 1; audio.master.connect(audio.ctx.destination);\n        for (const id in audio.raw) audio.raw[id].then(ab => {\n          if (!ab) return;\n          return audio.ctx.decodeAudioData(ab.slice(0)).then(b => { audio.buffers[id] = b; if (id === 'music_battle' && audio.wantMusic) startMusic(); });\n        }).catch(() => {});\n      } catch (e) { audio.ctx = null; }\n    }\n    if (audio.ctx && audio.ctx.state === 'suspended') audio.ctx.resume();\n  };\n  window.addEventListener('pointerdown', unlock); window.addEventListener('pointerup', unlock); window.addEventListener('keydown', unlock);\n}\nfunction sfx(id, vol, rate, throttle) {\n  if (!audio.ctx || !audio.buffers[id]) return;\n  if (throttle) { if (time - (audio.last[id] || -9) < throttle) return; audio.last[id] = time; }\n  const s = audio.ctx.createBufferSource(); s.buffer = audio.buffers[id]; s.playbackRate.value = rate || 1;\n  const g = audio.ctx.createGain(); g.gain.value = vol == null ? 0.6 : vol;\n  s.connect(g); g.connect(audio.master); s.start();\n}\nfunction startMusic() {\n  audio.wantMusic = true;\n  if (!audio.ctx || audio.music || !audio.buffers.music_battle) return;\n  const s = audio.ctx.createBufferSource(); s.buffer = audio.buffers.music_battle; s.loop = true;\n  const g = audio.ctx.createGain(); g.gain.value = 0.28; s.connect(g); g.connect(audio.master); s.start(); audio.music = s;\n}\nfunction toggleMute() {\n  audio.muted = !audio.muted;\n  if (audio.master) audio.master.gain.value = audio.muted ? 0 : 1;\n  document.getElementById('muteBtn').textContent = audio.muted ? '\ud83d\udd07' : '\ud83d\udd0a';\n}\n\n// ============ save / leaderboard ============\nasync function loadSave() {\n  try {\n    const r = await Promise.race([lib.getUserGameState(), new Promise(res => setTimeout(() => res(null), 2500))]);\n    if (r && r.state) save = Object.assign(save, r.state);\n  } catch (e) { }\n  save.level = Math.max(1, save.level | 0);\n}\nfunction persist() { try { lib.saveUserGameState({ level: save.level, best: save.best, wins: save.wins }).catch(() => {}); } catch (e) { } }\nfunction submitScore() { try { lib.addPlayerScoreToLeaderboard(save.best).catch(() => {}); } catch (e) { } }\n\n// ============ world building ============\nfunction track(o) { levelDisposables.push(o); return o; }\nfunction lam(hex) { return track(new THREE.MeshLambertMaterial({ color: lin(hex) })); }\nfunction canvasTex(w, h, draw, rx, ry) {\n  const c = document.createElement('canvas'); c.width = w; c.height = h; draw(c.getContext('2d'), w, h);\n  const t = new THREE.CanvasTexture(c); t.encoding = THREE.sRGBEncoding; t.anisotropy = renderer.capabilities.getMaxAnisotropy();\n  if (rx) { t.wrapS = t.wrapT = THREE.RepeatWrapping; t.repeat.set(rx, ry); }\n  return track(t);\n}\nfunction noise(ctx, w, h, base, vars, n, sz) {\n  ctx.fillStyle = base; ctx.fillRect(0, 0, w, h);\n  for (let i = 0; i < n; i++) { ctx.fillStyle = vars[(Math.random() * vars.length) | 0]; ctx.globalAlpha = rand(0.15, 0.5); ctx.fillRect(Math.random() * w, Math.random() * h, rand(1, sz), rand(1, sz)); }\n  ctx.globalAlpha = 1;\n}\nfunction clearLevel() {\n  if (levelGroup) { scene.remove(levelGroup); levelGroup.traverse(o => { if (o.isInstancedMesh) o.dispose(); if (o.geometry && o.userData.own) o.geometry.dispose(); }); }\n  for (const d of levelDisposables) if (d && d.dispose) d.dispose();\n  levelDisposables = []; gateObjs = []; fortressObj = null;\n  for (const c of corpses) c.on = false;\n  for (let p = 0; p < PRCAP; p++) PR.on[p] = 0;\n  for (const P of [FXA, FXS]) P.life.fill(0);\n  clearTags();\n}\nfunction mesh(geo, mat, x, y, z, ry) { const m = new THREE.Mesh(geo, mat); m.position.set(x, y, z); if (ry) m.rotation.y = ry; m.castShadow = true; m.receiveShadow = true; levelGroup.add(m); return m; }\nfunction boxGeo(w, h, d) { return track(new THREE.BoxGeometry(w, h, d)); }\nfunction scatter(geo, mat, list, shadow) {\n  const m = new THREE.InstancedMesh(geo, mat, Math.max(1, list.length));\n  list.forEach((o, i) => { rotYXZ(RB, o.r || 0, 0, 0); writeM(m.instanceMatrix.array, i, RB, o.sx || 1, o.sy || 1, o.sz || 1, o.x, o.y, o.z); });\n  m.count = list.length; m.castShadow = !!shadow; m.receiveShadow = true; levelGroup.add(m); return m;\n}\nfunction buildEnvironment(z0, z1) {\n  const len = z0 - z1, mid = (z0 + z1) / 2;\n  const grassTex = canvasTex(256, 256, (x, w, h) => noise(x, w, h, '#78b84a', ['#5f9e3a', '#8fcf5a', '#6aa843', '#9bd46a'], 2600, 5), 90, (len + 600) / 9);\n  const gg = track(new THREE.PlaneGeometry(800, len + 600)); gg.rotateX(-Math.PI / 2);\n  const ground = new THREE.Mesh(gg, track(new THREE.MeshLambertMaterial({ map: grassTex }))); ground.position.set(0, -0.05, mid); ground.receiveShadow = true; levelGroup.add(ground);\n  const roadTex = canvasTex(512, 512, (x, w, h) => {\n    noise(x, w, h, '#646b77', ['#596069', '#727a86', '#50565f', '#7c8490'], 9000, 3);\n    x.fillStyle = '#e9e9e9'; x.fillRect(18, 0, 10, h); x.fillRect(w - 28, 0, 10, h);\n    for (let y = 0; y < h; y += 128) { x.fillRect(w / 2 - 5, y, 10, 64); x.fillRect(w / 4 - 4, y + 64, 8, 48); x.fillRect(w * 3 / 4 - 4, y + 64, 8, 48); }\n  }, 1, (len + 200) / 46);\n  const rg = track(new THREE.PlaneGeometry(ROAD_HALF * 2 + 4, len + 200)); rg.rotateX(-Math.PI / 2);\n  const road = new THREE.Mesh(rg, track(new THREE.MeshLambertMaterial({ map: roadTex }))); road.position.set(0, 0, mid - 60); road.receiveShadow = true; levelGroup.add(road);\n  // barriers, props, trees, hills\n  const barriers = [], stripes = [], trunks = [], crowns = [], crowns2 = [], rocks = [], bags = [], crates = [], tents = [], hills = [];\n  for (let z = z0 + 30; z > z1 - 150; z -= 4) for (const s of [-1, 1]) { barriers.push({ x: s * (ROAD_HALF + 2.6), y: 0.45, z, sx: 0.7, sy: 0.9, sz: 3.6 }); if (((z / 4) | 0) % 2) stripes.push({ x: s * (ROAD_HALF + 2.6), y: 0.92, z, sx: 0.72, sy: 0.12, sz: 3.6 }); }\n  for (let z = z0 + 80; z > z1 - 250; z -= 2.2) for (const s of [-1, 1]) {\n    const r = Math.random();\n    if (r < 0.55) { const x = s * rand(29, 110), k = rand(0.9, 2.0); trunks.push({ x, y: 1 * k, z: z + rand(-1, 1), sx: 0.5 * k, sy: 2 * k, sz: 0.5 * k }); crowns.push({ x, y: 2.9 * k, z, sx: 2.2 * k, sy: 2.2 * k, sz: 2.2 * k, r: rand(0, 3) }); if (Math.random() < 0.6) crowns2.push({ x, y: 4.2 * k, z, sx: 1.4 * k, sy: 1.6 * k, sz: 1.4 * k, r: rand(0, 3) }); }\n    else if (r < 0.62) rocks.push({ x: s * rand(27, 80), y: 0.3, z, r: rand(0, 3), sx: rand(1, 2.5), sy: rand(0.6, 1.4), sz: rand(1, 2.5) });\n    else if (r < 0.67) bags.push({ x: s * rand(26.5, 28), y: 0.3, z, r: Math.PI / 2 + rand(-0.2, 0.2), sx: 1.6, sy: 0.6, sz: 0.8 });\n    else if (r < 0.7) crates.push({ x: s * rand(26.5, 30), y: 0.55, z, r: rand(0, 1), sx: 1.1, sy: 1.1, sz: 1.1 });\n    else if (r < 0.72) tents.push({ x: s * rand(32, 50), y: 1.2, z, r: Math.PI / 4 + rand(-0.2, 0.2), sx: 2.6, sy: 2.4, sz: 3.4 });\n  }\n  for (let z = z0; z > z1 - 400; z -= 70) for (const s of [-1, 1]) hills.push({ x: s * rand(160, 260), y: 0, z: z + rand(-30, 30), sx: rand(50, 90), sy: rand(25, 55), sz: rand(50, 90), r: rand(0, 3) });\n  scatter(boxGeo(1, 1, 1), lam(0xc9ccd2), barriers, true);\n  scatter(boxGeo(1, 1, 1), lam(0xe0393a), stripes, false);\n  scatter(boxGeo(1, 1, 1), lam(0x7a5532), trunks, true);\n  scatter(track(new THREE.IcosahedronGeometry(1, 0)), lam(0x3f9a46), crowns, true);\n  scatter(track(new THREE.IcosahedronGeometry(1, 0)), lam(0x58b45a), crowns2, true);\n  scatter(track(new THREE.DodecahedronGeometry(1, 0)), lam(0x9a9a9a), rocks, true);\n  scatter(boxGeo(1, 1, 1), lam(0xc8b27a), bags, true);\n  scatter(boxGeo(1, 1, 1), lam(0xb98b4e), crates, true);\n  const tg = track(new THREE.ConeGeometry(0.7, 1, 4)); scatter(tg, lam(0x6b7b3a), tents, true);\n  const hg = track(new THREE.ConeGeometry(1, 1, 7)); scatter(hg, lam(0x6f9b58), hills, false);\n}\nfunction drawIcon(ctx, type, cx, cy, size, color) {\n  ctx.save(); ctx.translate(cx - size / 2, cy - size / 2); ctx.scale(size / 24, size / 24); ctx.fillStyle = color; ctx.fill(new Path2D(ICON_PATHS[type])); ctx.restore();\n}\nfunction rrect(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); }\nfunction gateTexture(gd, aspect) {\n  const H = 384, Wd = Math.round(H * aspect);\n  return canvasTex(Wd, H, (x, w, h) => {\n    const col = TYPE_COLOR[UNIT_TYPES[gd.main]];\n    const gr = x.createLinearGradient(0, 0, 0, h); gr.addColorStop(0, col); gr.addColorStop(1, 'rgba(255,255,255,0.35)');\n    x.globalAlpha = 0.78; x.fillStyle = gr; rrect(x, 8, 8, w - 16, h - 16, 30); x.fill();\n    x.globalAlpha = 1; x.lineWidth = 12; x.strokeStyle = '#fff'; x.stroke();\n    x.textAlign = 'center'; x.textBaseline = 'middle';\n    const font = s => `900 ${s}px \"Arial Black\", Impact, sans-serif`;\n    const out = (t, px, py, s) => { x.font = font(s); x.lineWidth = s / 6; x.strokeStyle = '#0b1e45'; x.strokeText(t, px, py); x.fillStyle = '#fff'; x.fillText(t, px, py); };\n    const types = UNIT_TYPES.map((_, k) => k).filter(k => gd.units[k]);\n    if (types.length === 1) {\n      const k = types[0];\n      drawIcon(x, UNIT_TYPES[k], w * 0.27, h * 0.42, 150, '#fff');\n      out('+' + fmt(gd.units[k]), w * 0.66, h * 0.36, gd.units[k] >= 1000 ? 92 : 112);\n      out(UNIT_SHORT[UNIT_TYPES[k]], w * 0.66, h * 0.6, 40);\n    } else {\n      types.forEach((k, i) => { const yy = h * (0.3 + i * 0.3); drawIcon(x, UNIT_TYPES[k], w * 0.28, yy, 96, '#fff'); out('+' + fmt(gd.units[k]), w * 0.62, yy + 4, 80); });\n    }\n    const beats = COUNTERS[gd.main].S;\n    const bw = 150 + beats.length * 56;\n    x.fillStyle = 'rgba(11,30,69,.82)'; rrect(x, w / 2 - bw / 2, h - 88, bw, 60, 20); x.fill();\n    x.font = font(30); x.fillStyle = '#ffe27a'; x.textAlign = 'left'; x.fillText('BEATS', w / 2 - bw / 2 + 18, h - 57);\n    beats.forEach((b, q) => { const bx = w / 2 - bw / 2 + 140 + q * 56; x.fillStyle = TYPE_COLOR[UNIT_TYPES[b]]; rrect(x, bx, h - 82, 48, 48, 10); x.fill(); drawIcon(x, UNIT_TYPES[b], bx + 24, h - 58, 38, '#fff'); });\n  });\n}\nfunction buildGates() {\n  const postMat = lam(0xf2f2f2);\n  for (const row of RUN.rows) {\n    const objs = [];\n    row.gates.forEach(gd => {\n      const w = gd.x1 - gd.x0, cx = (gd.x0 + gd.x1) / 2, pw = w - 2.2, ph = 7;\n      const pm = track(new THREE.MeshBasicMaterial({ map: gateTexture(gd, pw / ph), transparent: true, depthWrite: false, side: THREE.DoubleSide }));\n      const panel = new THREE.Mesh(track(new THREE.PlaneGeometry(pw, ph)), pm); panel.position.set(cx, 4.4, row.z); levelGroup.add(panel);\n      for (const s of [-1, 1]) mesh(boxGeo(0.6, 9, 0.6), postMat, cx + s * (w / 2 - 0.7), 4.5, row.z);\n      const bar = mesh(boxGeo(w - 0.8, 0.8, 0.8), lam(TYPE_HEX[gd.main]), cx, 8.6, row.z);\n      objs.push({ panel, pm, bar });\n    });\n    gateObjs.push({ row, objs, anim: 0 });\n  }\n}\nfunction bannerTexture(text) {\n  return canvasTex(512, 96, (x, w, h) => {\n    x.fillStyle = '#c62828'; x.fillRect(0, 0, w, h);\n    x.font = '900 64px \"Arial Black\", Impact, sans-serif'; x.textAlign = 'center'; x.textBaseline = 'middle';\n    x.lineWidth = 8; x.strokeStyle = '#3a0a0a'; x.strokeText(text, w / 2, h / 2 + 4); x.fillStyle = '#fff'; x.fillText(text, w / 2, h / 2 + 4);\n  });\n}\nfunction flag(x, y, z) {\n  mesh(boxGeo(0.18, 4, 0.18), lam(0xdedede), x, y + 2, z);\n  const f = mesh(boxGeo(2.4, 1.4, 0.1), lam(0xe0393a), x + 1.25, y + 3.3, z); f.userData.flag = true;\n}\nfunction buildOutpost(g) {\n  const ez = g.homeZ, hd = g.halfD;\n  const wood = lam(0x7a5532), bag = lam(0xc8b27a), roof = lam(0x99201f);\n  for (const s of [-1, 1]) {\n    for (let z = ez + hd + 6; z > ez - hd - 6; z -= 2) { mesh(boxGeo(0.9, 0.6, 1.8), bag, s * (ROAD_HALF + 0.5), 0.3, z); mesh(boxGeo(0.9, 0.6, 1.8), bag, s * (ROAD_HALF + 0.5), 0.9, z - 1); }\n    for (const lx of [-1, 1]) for (const lz of [-1, 1]) mesh(boxGeo(0.35, 7, 0.35), wood, s * 27 + lx * 1.4, 3.5, ez + lz * 1.4);\n    mesh(boxGeo(4, 2.2, 4), wood, s * 27, 7.8, ez);\n    const r = mesh(track(new THREE.ConeGeometry(3.4, 2, 4)), roof, s * 27, 9.9, ez); r.rotation.y = Math.PI / 4;\n    flag(s * 27, 11, ez);\n  }\n  const az = ez - hd - 5;\n  for (const s of [-1, 1]) mesh(boxGeo(1.4, 11, 1.4), lam(0x8a8f99), s * (ROAD_HALF + 1.5), 5.5, az);\n  const banner = new THREE.Mesh(boxGeo(ROAD_HALF * 2 + 4, 2.6, 0.4), track(new THREE.MeshBasicMaterial({ map: bannerTexture('OUTPOST') })));\n  banner.position.set(0, 10, az); levelGroup.add(banner);\n}\nfunction buildFortress(z) {\n  const g = new THREE.Group(); g.position.z = z; levelGroup.add(g);\n  const stone = lam(0x8a8f99), stoneD = lam(0x6d727c), roof = lam(0x99201f);\n  const add = (geo, mat, x, y, zz, ry) => { const m = new THREE.Mesh(geo, mat); m.position.set(x, y, zz); if (ry) m.rotation.y = ry; m.castShadow = m.receiveShadow = true; g.add(m); return m; };\n  add(boxGeo(70, 10, 4), stone, 0, 5, 0);\n  for (let x = -34; x <= 34; x += 3.4) add(boxGeo(1.8, 1.6, 4.4), stone, x, 10.8, 0);\n  for (const s of [-1, 1]) for (const tx of [14, 30]) {\n    add(boxGeo(8, 18, 8), stoneD, s * tx, 9, 0.5);\n    const r = add(track(new THREE.ConeGeometry(6.4, 6, 4)), roof, s * tx, 21, 0.5); r.rotation.y = Math.PI / 4;\n    const p = add(boxGeo(0.2, 5, 0.2), lam(0xdedede), s * tx, 26, 0.5); add(boxGeo(3, 1.8, 0.1), lam(0xe0393a), s * tx + 1.6, 27.6, 0.5);\n  }\n  add(boxGeo(10, 7.5, 0.6), lam(0x5a3d22), 0, 3.75, 2.2);\n  const banner = new THREE.Mesh(boxGeo(18, 3.2, 0.4), track(new THREE.MeshBasicMaterial({ map: bannerTexture('FORTRESS') }))); banner.position.set(0, 8.6, 2.4); g.add(banner);\n  fortressObj = { group: g, z };\n}\nfunction startLevel(n) {\n  clearLevel();\n  levelGroup = new THREE.Group(); scene.add(levelGroup);\n  SIMCB.onSpawn = onUnitSpawn; SIMCB.onShot = onShot; SIMCB.onKill = onKill; SIMCB.onImpact = onImpact;\n  runStart(n, cfg, SALT);\n  RUN.cb = { onGate, onWarn, onAlert, onEnemyDead, onWin, onLose, onNewType };\n  for (let i = 0; i < U.hi; i++) if (U.alive[i]) onUnitSpawn(i);\n  buildEnvironment(60, RUN.fortressZ - 30);\n  buildGates();\n  for (const g of RUN.enemies) if (g.tier === 'outpost') buildOutpost(g);\n  buildFortress(RUN.fortressZ);\n  camPos.set(0, 14, 26); camLook.set(0, 1, -10);\n  document.getElementById('lvl').textContent = 'LEVEL ' + n;\n  document.getElementById('menuLevel').textContent = n;\n  buildMarkers();\n  hud.lastCnt = new Array(NT).fill(-1);\n}\n\n// ============ HUD ============\nconst hud = { lastCnt: new Array(8).fill(-1), tags: new Map() };\nfunction initHUD() {\n  hud.tagsEl = document.getElementById('tags'); hud.floats = document.getElementById('floats');\n  hud.toast = document.getElementById('toast'); hud.hint = document.getElementById('hint'); hud.progFill = document.getElementById('progFill');\n  document.getElementById('legend').style.display = 'none';\n  const rules = UNIT_TYPES.map((t, k) =>\n    `<div class=\"rule\">${icBox(t)}<div class=\"rn\">${UNIT_NAMES[t]}</div><div class=\"beats\">beats</div><div class=\"bi\">${COUNTERS[k].S.map(b => icBox(UNIT_TYPES[b])).join('')}</div></div>`).join('');\n  document.getElementById('loseTip').innerHTML = `<h3>TIP: GRAB GATES THAT BEAT THE NEXT RED ARMY</h3>`;\n  const bar = document.getElementById('armybar');\n  bar.innerHTML = UNIT_TYPES.map(t => `<div class=\"chip\" id=\"chip_${t}\">${icBox(t)}<span id=\"cnt_${t}\">0</span></div>`).join('');\n  hud.chips = UNIT_TYPES.map(t => document.getElementById('chip_' + t)); hud.cnt = UNIT_TYPES.map(t => document.getElementById('cnt_' + t));\n  hud.lastCnt = new Array(NT).fill(-1);\n  hud.ptag = document.createElement('div'); hud.ptag.className = 'tag blue stroke'; hud.tagsEl.appendChild(hud.ptag);\n  document.getElementById('muteBtn').addEventListener('pointerdown', e => { e.stopPropagation(); toggleMute(); });\n  document.getElementById('playBtn').addEventListener('click', startRun);\n  document.getElementById('nextBtn').addEventListener('click', () => { hide('winPanel'); startLevel(save.level); startRun(); });\n  document.getElementById('retryBtn').addEventListener('click', () => { hide('losePanel'); startLevel(save.level); startRun(); });\n  let taps = 0, tapT = 0;\n  document.getElementById('title').addEventListener('click', () => {\n    if (time - tapT > 1.5) taps = 0; tapT = time;\n    if (++taps >= 7) { const s = document.getElementById('sig'); s.textContent = 'Original game by ' + SALT.split('|')[0]; s.classList.add('show'); taps = 0; }\n  });\n}\nfunction show(id) { document.getElementById(id).classList.remove('hidden'); if (id === 'menu') document.getElementById('hud').classList.add('in-menu'); }\nfunction hide(id) { document.getElementById(id).classList.add('hidden'); }\nfunction updateArmyBar() {\n  const c = groupCounts(RUN.player);\n  for (let k = 0; k < NT; k++) {\n    if (c[k] === hud.lastCnt[k]) continue;\n    const ch = hud.chips[k];\n    if (c[k] > hud.lastCnt[k] && hud.lastCnt[k] >= 0) { ch.classList.add('pop'); setTimeout(() => ch.classList.remove('pop'), 140); }\n    hud.cnt[k].textContent = fmt(c[k]); ch.style.display = c[k] > 0 || k === 0 ? '' : 'none'; ch.style.opacity = c[k] > 0 ? 1 : 0.55; hud.lastCnt[k] = c[k];\n  }\n}\nconst _v = new THREE.Vector3();\nfunction placeEl(el, x, y, z) {\n  _v.set(x, y, z).project(camera);\n  if (_v.z > 1 || _v.z < -1 || _v.y < -1.2 || _v.y > 1.3) { el.style.display = 'none'; return false; }\n  el.style.display = '';\n  el.style.transform = `translate(${((_v.x + 1) / 2 * W).toFixed(1)}px,${((1 - _v.y) / 2 * H).toFixed(1)}px) translate(-50%,-100%)`;\n  return true;\n}\nfunction getTag(key, cls) {\n  let t = hud.tags.get(key);\n  if (!t) { t = { el: document.createElement('div'), html: '' }; t.el.className = 'tag stroke ' + cls; hud.tagsEl.appendChild(t.el); hud.tags.set(key, t); }\n  t.seen = true; return t;\n}\nfunction clearTags() { for (const [, t] of hud.tags) t.el.remove(); hud.tags.clear(); }\nfunction setHTML(t, html) { if (t.html !== html) { t.el.innerHTML = html; t.html = html; } }\nfunction dominantType(c) { const v = c.map((n, k) => n * cfg.units[UNIT_TYPES[k]].value); return v.indexOf(Math.max(...v)); }\nfunction updateLabels() {\n  hud.ptag.style.display = 'none';\n  const P = RUN.player;\n  if (false) {\n    const c = groupCounts(P);\n    const html = `<div class=\"row\">${UNIT_TYPES.map((_, k) => k).filter(k => c[k] > 0).map(k => `<span class=\"u\">${iconSVG(UNIT_TYPES[k], '#fff')}${fmt(c[k])}</span>`).join('')}</div>`;\n    if (hud.phtml !== html) { hud.ptag.innerHTML = html; hud.phtml = html; }\n    placeEl(hud.ptag, RUN.pcx, c[5] > 0 ? 12 : c[2] > 0 ? 9 : c[7] > 0 ? 7 : 4, RUN.pcz - P.halfD * 0.2);\n  } else hud.ptag.style.display = 'none';\n  for (const [, t] of hud.tags) t.seen = false;\n  const pc = groupCounts(P), dom = dominantType(pc);\n  for (const g of RUN.enemies) {\n    if (!g.alive) continue;\n    const dist = RUN.pcz - g.cz;\n    if (dist > 210 || appState === 'menu') continue;\n    const c = groupCounts(g);\n    let soft = -1, bm = 1.01;\n    for (let k = 0; k < NT; k++) if (c[k] > 0) { const mm = counterMultK(cfg, dom, k); if (mm > bm) { bm = mm; soft = k; } }\n    const ks = UNIT_TYPES.map((_, k) => k).filter(k => c[k] > 0).sort((a, b) => g.clusterX[a] - g.clusterX[b]);\n    const t = getTag('g' + g.id, 'red');\n    setHTML(t, (g.tier !== 'normal' ? `<div class=\"tier\">${g.tier === 'fortress' ? '\ud83c\udff0 FORTRESS' : '\u2691 OUTPOST'}</div>` : '') + `<div class=\"row\">${ks.map(k => `<span class=\"u${k === soft && pc[dom] > 0 ? ' hit' : ''}\">${iconSVG(UNIT_TYPES[k], '#fff')}${fmt(c[k])}</span>`).join('')}</div>`);\n    placeEl(t.el, g.cx, ks.some(k => UNIT_LAYER[k]) ? 15 : 8, g.cz - g.halfD * 0.3);\n  }\n  for (const [key, t] of hud.tags) if (!t.seen) { t.el.remove(); hud.tags.delete(key); }\n}\nfunction floatText(html, color, x, y, z) {\n  const el = document.createElement('div'); el.className = 'float stroke'; el.style.color = color; el.innerHTML = html;\n  hud.floats.appendChild(el);\n  const st = performance.now();\n  (function step() {\n    const k = (performance.now() - st) / 1000;\n    if (k > 1.2 || !el.isConnected) { el.remove(); return; }\n    placeEl(el, x, y + k * 5, z); el.style.opacity = k < 0.8 ? 1 : 1 - (k - 0.8) / 0.4;\n    requestAnimationFrame(step);\n  })();\n}\nlet toastTimer = 0;\nfunction toast(html, dur) { hud.toast.innerHTML = html; hud.toast.classList.add('show'); toastTimer = dur || 1.8; }\nfunction buildMarkers() {\n  const mk = document.getElementById('markers'); mk.innerHTML = '';\n  const total = -RUN.fortressZ;\n  for (const g of RUN.enemies) {\n    if (g.tier === 'normal') continue;\n    const d = document.createElement('div'); d.className = 'marker' + (g.tier === 'fortress' ? ' fort' : '');\n    d.style.left = clamp((-g.cz) / total * 100, 0, 100) + '%'; d.textContent = g.tier === 'fortress' ? '\ud83c\udff0' : '\u2691';\n    mk.appendChild(d);\n  }\n}\n\n// ============ run callbacks ============\nfunction onGate(row, idx) {\n  const gd = row.gates[idx];\n  sfx('sfx_gate', 0.6);\n  const html = UNIT_TYPES.map((_, k) => k).filter(k => gd.units[k]).map(k => `${iconSVG(UNIT_TYPES[k], TYPE_COLOR[UNIT_TYPES[k]])}+${fmt(gd.units[k])}`).join(' ');\n  floatText(html, '#fff', RUN.pcx, 8, RUN.pcz);\n  for (let q = 0; q < 24; q++) emitP(FXA, RUN.pcx + rand(-8, 8), rand(1, 8), row.z, rand(-3, 3), rand(2, 6), rand(-2, 2), rand(0.4, 0.8), rand(0.2, 0.4), TYPE_HEX[gd.main], 0.3);\n}\nfunction onNewType(k) {\n  const t = UNIT_TYPES[k];\n  toast(`<span style=\"display:inline-flex;vertical-align:middle;margin-right:10px\">${icBox(t)}</span>NEW UNIT: ${UNIT_NAMES[t]}!<small>Beats ${COUNTERS[k].S.map(b => UNIT_SHORT[UNIT_TYPES[b]].toLowerCase()).join(', ')}</small>`, 2.8);\n}\nfunction onWarn(tier) { toast(tier === 'fortress' ? '\u26a0 FORTRESS AHEAD!<small>Reinforcement zone: build the biggest army you can</small>' : '\u26a0 OUTPOST AHEAD!<small>Reinforcement zone: stock up</small>', 2.4); }\nfunction onAlert(g) {\n  if (g.tier === 'fortress') toast('FINAL ASSAULT!<small>Smash into the units you counter</small>', 1.6);\n  else if (g.tier === 'outpost') toast('ATTACK THE OUTPOST!', 1.4);\n}\nfunction onEnemyDead(g) {\n  if (g.tier === 'outpost') toast('OUTPOST CAPTURED!', 1.5);\n  else if (g.tier === 'normal') floatText('CLEARED!', '#7CFC8A', RUN.pcx, 9, RUN.pcz - RUN.player.halfD);\n}\nfunction onWin() { appState = 'won'; endTimer = 0; fortressBlast(); }\nfunction onLose() { appState = 'lost'; endTimer = 0; }\nfunction fortressBlast() {\n  if (!fortressObj) return;\n  const z = fortressObj.z;\n  for (let i = 0; i < 16; i++) setTimeout(() => { explosion(rand(-32, 32), rand(3, 16), z + rand(-1, 3), rand(2, 3.5)); shake = 1.4; sfx('sfx_explosion', 0.6, rand(0.75, 1.05), 0.07); }, i * 90);\n  setTimeout(() => {\n    if (fortressObj) fortressObj.group.visible = false;\n    for (let i = 0; i < 90; i++) emitP(FXS, rand(-34, 34), rand(1, 18), z, rand(-10, 10), rand(6, 18), rand(-4, 12), rand(1.2, 2.2), rand(0.6, 1.8), i % 2 ? 0x9aa0aa : 0x7d838e, 1);\n    for (let i = 0; i < 30; i++) emitSmoke(rand(-34, 34), rand(2, 10), z, 3);\n  }, 600);\n}\nfunction showWin() {\n  const cleared = RUN.level;\n  save.wins++; save.best = Math.max(save.best, cleared); save.level = cleared + 1;\n  persist(); submitScore(); sfx('sfx_victory', 0.8);\n  document.getElementById('winText').textContent = `Level ${cleared} fortress captured!`;\n  const c = groupCounts(RUN.player);\n  document.getElementById('winSurv').innerHTML = UNIT_TYPES.map((t, k) => c[k] > 0 ? `<div class=\"chip\">${icBox(t)}<span>${fmt(c[k])}</span></div>` : '').join('');\n  show('winPanel');\n}\nfunction showLose() {\n  const en = nextEnemyGroup();\n  document.getElementById('loseText').textContent = en && en.tier !== 'normal' ? `The ${en.tier} held strong. Build a bigger army!` : 'Your army was wiped out';\n  show('losePanel');\n}\n\n// ============ input ============\nconst keys = {};\nlet drag = null;\nfunction initInput() {\n  const cv = document.getElementById('c');\n  const down = e => { drag = { id: e.pointerId, x: e.clientX, cx: RUN.targetCx }; try { cv.setPointerCapture(e.pointerId); } catch (_) { } };\n  const move = e => { if (drag && e.pointerId === drag.id) RUN.targetCx = drag.cx + (e.clientX - drag.x) / W * 46 * cfg.steerSensitivity; };\n  const up = e => { if (drag && (!e || e.pointerId === drag.id)) drag = null; };\n  cv.addEventListener('pointerdown', down);\n  window.addEventListener('pointermove', move);\n  window.addEventListener('pointerup', up); window.addEventListener('pointercancel', up);\n  cv.addEventListener('lostpointercapture', up);\n  window.addEventListener('blur', () => { drag = null; for (const k in keys) keys[k] = false; });\n  window.addEventListener('keydown', e => { keys[e.key] = true; if ((e.key === ' ' || e.key === 'Enter') && appState === 'menu') startRun(); });\n  window.addEventListener('keyup', e => { keys[e.key] = false; });\n}\nfunction applyKeys(dt) {\n  const kv = (keys.ArrowLeft || keys.a || keys.A ? -1 : 0) + (keys.ArrowRight || keys.d || keys.D ? 1 : 0);\n  if (kv) RUN.targetCx += kv * 24 * cfg.steerSensitivity * dt;\n  // keep the drag anchor in step with clamping so steering never feels stuck at an edge\n  const lim = Math.max(0, ROAD_HALF - 1 - RUN.player.halfW);\n  if (RUN.targetCx > lim || RUN.targetCx < -lim) {\n    const c = clamp(RUN.targetCx, -lim, lim);\n    if (drag) drag.cx += c - RUN.targetCx;\n    RUN.targetCx = c;\n  }\n}\n\n// ============ flow ============\n// Welcome screen: a parade of every unit type marching toward the camera\nconst SHOWCASE = [150, 9, 6, 44, 3, 4, 3, 2];\nfunction menuShowcase() {\n  const P = RUN.player;\n  RUN.pcz = 70; RUN.pcx = 0; P.cz = 70; P.cx = 0;\n  for (let k = 0; k < NT; k++) if (SHOWCASE[k] > groupTotal(P, k)) addUnits(P, k, SHOWCASE[k] - groupTotal(P, k), 0, 70, 10);\n  computeOffsets(P);\n  for (let k = 0; k < NT; k++) for (const i of P.lists[k]) { U.x[i] = P.cx + P.offsets[k][U.slot[i] * 2]; U.z[i] = P.cz + P.offsets[k][U.slot[i] * 2 + 1]; U.yaw[i] = 0; hullYaw[i] = 0; spawnAt[i] = -9; }\n  document.getElementById('menuLevel').textContent = save.level;\n  document.getElementById('menuBest').textContent = save.best > 0 ? save.best : '-';\n  document.getElementById('menuWins').textContent = save.wins;\n  window.__snapCam = true;\n}\nfunction fitLogo() {\n  // scale each logo line to fit the screen whatever font the device has\n  for (const el of document.querySelectorAll('.logo-word')) {\n    el.style.transform = '';\n    const w = el.querySelector('.lw-back').scrollWidth + 24, max = 680;\n    if (w > max) el.style.transform = `scale(${(max / w).toFixed(3)})`;\n  }\n}\nfunction openMenu() {\n  fitLogo(); setTimeout(fitLogo, 600); startLevel(save.level); menuShowcase(); appState = 'menu'; show('menu'); }\nfunction startRun() {\n  if (appState === 'menu') startLevel(save.level);\n  document.getElementById('hud').classList.remove('in-menu');\n  hide('menu'); appState = 'run'; RUN.state = 'run'; introT = 0; startMusic();\n  hud.hint.classList.add('show'); setTimeout(() => hud.hint.classList.remove('show'), 3500);\n  toast(`LEVEL ${RUN.level}<small>Grow your army, then take the fortress!</small>`, 2.2);\n}\nlet gunT = 0;\nfunction updateCamera(dt) {\n  const P = RUN.player;\n  const pitch = clamp(cfg.cameraPitch, 12, 80) * Math.PI / 180;\n  // low chase camera behind the army; pulls back as the army grows\n  let dist = (24 + Math.max(P.halfW * 1.35, P.halfD * 0.9)) * clamp(cfg.cameraZoom, 0.4, 2.5);\n  if (engagedBlue > 0) dist *= 1.1;\n  const ahead = 14 + P.halfD * 0.35 + (engagedBlue > 0 ? 6 : 0);\n  let lx = RUN.pcx * 0.7, lz = RUN.pcz - ahead, ly = 2;\n  let px = RUN.pcx * 0.85, py = Math.sin(pitch) * dist + 2, pz = RUN.pcz + P.halfD * 0.5 + Math.cos(pitch) * dist * 0.75;\n  if (appState === 'menu') { const a = Math.sin(time * 0.18) * 0.55, d = 30 + P.halfW * 0.9; lx = 0; ly = 3.2; lz = RUN.pcz - 2; px = Math.sin(a) * d; py = 6.5; pz = lz - Math.cos(a) * d; }\n  if (appState === 'won' && fortressObj) { lx = 0; lz = fortressObj.z + 4; ly = 6; px = Math.sin(time * 0.2) * 14; py = 26; pz = lz + 70; }\n  const k = Math.min(1, dt * (appState === 'run' && introT < 1.6 ? 1.6 : 2.4));\n  if (window.__snapCam) { window.__snapCam = false; camPos.set(px, py, pz); camLook.set(lx, ly, lz); }\n  camPos.x += (px - camPos.x) * k; camPos.y += (py - camPos.y) * k; camPos.z += (pz - camPos.z) * k;\n  camLook.x += (lx - camLook.x) * k; camLook.y += (ly - camLook.y) * k; camLook.z += (lz - camLook.z) * k;\n  shake = Math.max(0, shake - dt * 2.5);\n  camera.position.set(camPos.x + rand(-1, 1) * shake * 0.5, camPos.y + rand(-1, 1) * shake * 0.5, camPos.z);\n  camera.lookAt(camLook);\n  // sun & shadow frustum follow the view\n  const span = clamp(dist * 1.1, 45, 120);\n  sun.position.set(camLook.x - 40, 90, camLook.z + 30); sun.target.position.copy(camLook); sun.target.updateMatrixWorld();\n  const sc = sun.shadow.camera;\n  if (sc.right !== span) { sc.left = -span; sc.right = span; sc.top = span; sc.bottom = -span; sc.updateProjectionMatrix(); }\n}\n\n// adaptive quality for phones\nconst perf = { acc: 0, n: 0, level: 0, t: 0, cpu: 0 };\nfunction adaptQuality(dt) {\n  perf.acc += dt; perf.n++; perf.t += dt;\n  if (perf.t < 2.5) return;\n  const avg = perf.acc / perf.n; perf.acc = 0; perf.n = 0; perf.t = 0;\n  if (avg > 0.028 && perf.level < 3) {\n    perf.level++;\n    if (perf.level === 1) { sun.castShadow = false; }\n    else if (perf.level === 2) { renderer.setPixelRatio(1); onResize(); }\n    else { renderer.setPixelRatio(0.75); onResize(); }\n  }\n}\n\nfunction loop(ts) {\n  requestAnimationFrame(loop);\n  const now = ts / 1000; let dt = lastT ? now - lastT : 0.016; lastT = now;\n  dt = Math.min(dt, 0.1); time += dt;\n  if (appState === 'loading') { renderer.render(scene, camera); return; }\n  const cpu0 = performance.now();\n  shotBudget = 45; impactBudget = 25; killFx = 30;\n  if (appState === 'run' || appState === 'won' || appState === 'lost') {\n    introT += dt;\n    applyKeys(dt);\n    runUpdate(dt);\n    if (appState === 'run' && engagedBlue > 0) { gunT -= dt; if (gunT <= 0) { sfx('sfx_gunfire', 0.4, rand(0.9, 1.15)); gunT = 0.75; } }\n  } else if (appState === 'menu') {\n    RUN.pcz = Math.max(-25, RUN.pcz - 4.2 * dt); RUN.player.cz = RUN.pcz; RUN.player.cx = 0;\n    simUpdate(dt);\n  }\n  if (appState === 'won') { endTimer += dt; if (endTimer > 2.2 && endTimer - dt <= 2.2) showWin(); }\n  if (appState === 'lost') { endTimer += dt; if (endTimer > 1.2 && endTimer - dt <= 1.2) showLose(); }\n  renderUnits(dt); renderCorpses(dt);\n  updatePool(FXA, dt); updatePool(FXS, dt); updateProjectiles(dt);\n  for (const go of gateObjs) {\n    if (!go.row.used || go.anim >= 1) continue;\n    go.anim = Math.min(1, go.anim + dt * 2);\n    go.objs.forEach((o, i) => {\n      if (i === go.row.chosen) { const s = 1 + go.anim * 0.35; o.panel.scale.set(s, s, s); o.pm.opacity = 1 - go.anim; }\n      else o.pm.opacity = Math.max(0.2, 1 - go.anim);\n    });\n  }\n  updateCamera(dt);\n  updateLabels();\n  hud.progFill.style.width = (clamp(-RUN.pcz / -RUN.fortressZ, 0, 1) * 100) + '%';\n  if (toastTimer > 0) { toastTimer -= dt; if (toastTimer <= 0) hud.toast.classList.remove('show'); }\n  perf.cpu = perf.cpu * 0.95 + (performance.now() - cpu0) * 0.05;\n  renderer.render(scene, camera);\n  adaptQuality(dt);\n}\n\nfunction onResize() {\n  W = window.innerWidth || 720; H = window.innerHeight || 1280;\n  renderer.setSize(W, H, false); camera.aspect = W / H; camera.updateProjectionMatrix();\n}\nfunction init() {\n  if (initDone) return; initDone = true;\n  W = window.innerWidth || 720; H = window.innerHeight || 1280;\n  renderer = new THREE.WebGLRenderer({ canvas: document.getElementById('c'), antialias: true, powerPreference: 'high-performance' });\n  renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1));\n  renderer.setSize(W, H, false);\n  renderer.outputEncoding = THREE.sRGBEncoding;\n  renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.05;\n  renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap;\n  scene = new THREE.Scene();\n  const sky = document.createElement('canvas'); sky.width = 4; sky.height = 256;\n  const sx = sky.getContext('2d'), gr = sx.createLinearGradient(0, 0, 0, 256);\n  gr.addColorStop(0, '#3f8fe0'); gr.addColorStop(0.55, '#8fc9f2'); gr.addColorStop(1, '#d9eefc');\n  sx.fillStyle = gr; sx.fillRect(0, 0, 4, 256);\n  const skyTex = new THREE.CanvasTexture(sky); skyTex.encoding = THREE.sRGBEncoding; scene.background = skyTex;\n  scene.fog = new THREE.Fog(lin(0xcfe8fb), 160, 460);\n  camera = new THREE.PerspectiveCamera(52, W / H, 0.5, 900);\n  scene.add(new THREE.HemisphereLight(lin(0xdff1ff), lin(0x5f7d45), 0.75));\n  sun = new THREE.DirectionalLight(lin(0xfff4e0), 1.35);\n  sun.castShadow = cfg.shadows !== false;\n  sun.shadow.mapSize.set(2048, 2048); sun.shadow.bias = -0.0006; sun.shadow.normalBias = 0.02;\n  sun.shadow.camera.near = 10; sun.shadow.camera.far = 260;\n  scene.add(sun); scene.add(sun.target);\n  buildUnitMeshes();\n  FXA = makePool(1800, true); FXS = makePool(1400, false); initProjectiles();\n  initHUD(); initInput(); initAudio();\n  window.addEventListener('resize', onResize);\n  requestAnimationFrame(loop);\n  loadSave().then(openMenu);\n}\n\nfunction run(mode) {\n  ensureConfig();\n  lib.showGameParameters({\n    name: '10,000 Army Runner Settings',\n    params: {\n      'Run Speed': { key: 'runSpeed', type: 'slider', min: 6, max: 24, step: 0.5 },\n      'Starting Soldiers': { key: 'startSoldiers', type: 'slider', min: 10, max: 300, step: 5 },\n      'Enemy Strength': { key: 'enemyStrength', type: 'slider', min: 0.2, max: 1.2, step: 0.05 },\n      'Difficulty Growth': { key: 'difficultyGrowth', type: 'slider', min: 0, max: 0.3, step: 0.01 },\n      'Combat Speed': { key: 'combatSpeed', type: 'slider', min: 0.5, max: 3, step: 0.1 },\n      'Counter Strength': { key: 'strongMultiplier', type: 'slider', min: 1, max: 3, step: 0.05 },\n      'Weakness Penalty': { key: 'weakMultiplier', type: 'slider', min: 0.2, max: 1, step: 0.05 },\n      'Camera Zoom': { key: 'cameraZoom', type: 'slider', min: 0.5, max: 2, step: 0.05 },\n      'Camera Angle': { key: 'cameraPitch', type: 'slider', min: 12, max: 70, step: 1 },\n      'Steer Sensitivity': { key: 'steerSensitivity', type: 'slider', min: 0.4, max: 2.5, step: 0.1 },\n      'Shadows': { key: 'shadows', type: 'checkbox', onChange: v => { cfg.shadows = v; if (sun) sun.castShadow = !!v; } }\n    }\n  });\n  init();\n}\n</script>\n<script>(() => {\n})();\n</script></body>\n</html>\n", "game_config": "{\n  \"runSpeed\": 12,\n  \"steerSensitivity\": 1,\n  \"startSoldiers\": 40,\n  \"difficultyGrowth\": 0.09,\n  \"combatSpeed\": 1,\n  \"enemyStrength\": 0.6,\n  \"strongMultiplier\": 1.8,\n  \"weakMultiplier\": 0.55,\n  \"shadows\": true,\n  \"cameraZoom\": 1,\n  \"cameraPitch\": 24,\n  \"units\": {\n    \"soldier\": {\n      \"hp\": 12,\n      \"damage\": 6,\n      \"interval\": 0.6,\n      \"range\": 13,\n      \"speed\": 17,\n      \"value\": 1,\n      \"splash\": 0\n    },\n    \"tank\": {\n      \"hp\": 125,\n      \"damage\": 55,\n      \"interval\": 1.5,\n      \"range\": 15,\n      \"speed\": 16,\n      \"value\": 9,\n      \"splash\": 2.4\n    },\n    \"heli\": {\n      \"hp\": 85,\n      \"damage\": 34,\n      \"interval\": 0.8,\n      \"range\": 11,\n      \"speed\": 20,\n      \"value\": 7,\n      \"splash\": 1.5\n    },\n    \"rocket\": {\n      \"hp\": 18,\n      \"damage\": 55,\n      \"interval\": 1.5,\n      \"range\": 16,\n      \"speed\": 16,\n      \"value\": 1.8,\n      \"splash\": 0\n    },\n    \"artillery\": {\n      \"hp\": 85,\n      \"damage\": 70,\n      \"interval\": 2.2,\n      \"range\": 22,\n      \"speed\": 13,\n      \"value\": 11,\n      \"splash\": 3.6\n    },\n    \"jet\": {\n      \"hp\": 80,\n      \"damage\": 32,\n      \"interval\": 0.9,\n      \"range\": 16,\n      \"speed\": 28,\n      \"value\": 8,\n      \"splash\": 1.2\n    },\n    \"aa\": {\n      \"hp\": 90,\n      \"damage\": 11,\n      \"interval\": 0.28,\n      \"range\": 18,\n      \"speed\": 15,\n      \"value\": 8,\n      \"splash\": 0\n    },\n    \"mech\": {\n      \"hp\": 900,\n      \"damage\": 95,\n      \"interval\": 1,\n      \"range\": 15,\n      \"speed\": 13,\n      \"value\": 40,\n      \"splash\": 2.2\n    }\n  }\n}", "game_design_document": "", "persistent_storage_config": {"leaderboard": {"sort_ascending": false}}, "game_metadata": {"game_id": "01M5NHP2RDGC6FHMEEQA9MFPMW", "title": "10,000 Army Runner", "description": "Lead the blue army! Pick soldiers, tanks or helicopters at each gate, counter the red armies, and storm the fortress.", "thumbnail_url": "https://d2oir5eh8rty2e.cloudfront.net/assets/images/f21e8e9700f84ffebd6b70b533ea4996.png", "created_at": 1792798297.0, "updated_at": 1792807426232.3416, "art_style": null}, "task_plan": null}