Skip to content

Instantly share code, notes, and snippets.

@ozbeksu
Created August 15, 2026 07:20
Show Gist options
  • Select an option

  • Save ozbeksu/5b07f806aa256f0db343e9e5cd43f346 to your computer and use it in GitHub Desktop.

Select an option

Save ozbeksu/5b07f806aa256f0db343e9e5cd43f346 to your computer and use it in GitHub Desktop.
Spec for building a 3D arena game as a SINGLE self-contained .html file.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>NEON ARENA</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='12' fill='%2300e5ff'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700;900&family=Rajdhani:wght@500;600;700&display=swap" rel="stylesheet">
<style>
:root{ --cyan:#00e5ff; --mag:#ff2d78; --bg:#05060f; }
*{ margin:0; padding:0; box-sizing:border-box; }
html,body{ width:100%; height:100%; overflow:hidden; background:var(--bg); }
body{ font-family:'Rajdhani',system-ui,sans-serif; color:#cfefff; user-select:none; -webkit-user-select:none; }
canvas{ display:block; }
/* ---------- HUD ---------- */
#hud{ position:fixed; inset:0; pointer-events:none; z-index:5; }
#scoreBox{ position:absolute; top:26px; left:30px; }
.label{ font-weight:600; font-size:13px; letter-spacing:.42em; color:#5f8fb8; opacity:.9; }
#score{
font-family:'Orbitron',monospace; font-weight:700; font-size:46px; line-height:1.05;
color:#eaffff; text-shadow:0 0 18px rgba(0,229,255,.75), 0 0 48px rgba(0,229,255,.3);
}
#score.pop{ animation:scorePop .25s ease-out; }
@keyframes scorePop{ 0%{transform:scale(1)} 35%{transform:scale(1.18)} 100%{transform:scale(1)} }
#comboWrap{ margin-top:10px; opacity:0; transition:opacity .3s; }
#comboX{ font-family:'Orbitron',monospace; font-weight:700; font-size:20px; color:var(--mag); text-shadow:0 0 14px rgba(255,45,120,.85); }
#comboBarOuter{ width:130px; height:3px; background:rgba(0,229,255,.15); margin-top:5px; overflow:hidden; }
#comboBar{ height:100%; width:100%; background:linear-gradient(90deg,var(--cyan),var(--mag)); box-shadow:0 0 10px rgba(0,229,255,.8); }
#levelChip{
position:absolute; top:30px; left:50%; transform:translateX(-50%);
font-family:'Orbitron',monospace; font-weight:700; font-size:15px; letter-spacing:.3em; padding:8px 16px 8px 20px;
color:#b48cff; border:1px solid rgba(150,100,255,.4); background:rgba(30,16,60,.35);
text-shadow:0 0 12px rgba(150,100,255,.8); backdrop-filter:blur(4px);
}
#levelChip.bump{ animation:chipBump .5s ease-out; }
@keyframes chipBump{
0%{ transform:translateX(-50%) scale(1); }
30%{ transform:translateX(-50%) scale(1.25); border-color:#b48cff; box-shadow:0 0 24px rgba(150,100,255,.5); }
100%{ transform:translateX(-50%) scale(1); }
}
#lives{ position:absolute; top:30px; right:30px; display:flex; gap:10px; }
#lives svg{ width:30px; height:30px; fill:var(--cyan); filter:drop-shadow(0 0 8px rgba(0,229,255,.8)); transition:all .3s; }
#lives svg.lost{ fill:#22303f; filter:none; transform:scale(.82) rotate(-18deg); animation:heartBreak .5s ease-out; }
@keyframes heartBreak{ 0%{ transform:scale(1.45); } 100%{ transform:scale(.82) rotate(-18deg); } }
/* ---------- overlays / fx ---------- */
#vignette{ position:fixed; inset:0; pointer-events:none; z-index:4;
background:radial-gradient(ellipse at center, transparent 52%, rgba(2,3,10,.62) 100%); }
#scanlines{ position:fixed; inset:0; pointer-events:none; z-index:4; opacity:.05; mix-blend-mode:overlay;
background:repeating-linear-gradient(0deg, transparent 0 2px, rgba(255,255,255,.5) 2px 3px); }
#flash{ position:fixed; inset:0; pointer-events:none; z-index:6; opacity:0; }
#msg{
position:fixed; top:22%; left:0; right:0; text-align:center; opacity:0; pointer-events:none; z-index:7;
font-family:'Orbitron',monospace; font-weight:900; font-size:44px; letter-spacing:.25em;
}
#msg.show{ animation:msgIn 1.5s ease-out forwards; }
@keyframes msgIn{
0%{ opacity:0; transform:scale(.6); }
18%{ opacity:1; transform:scale(1.06); }
30%{ transform:scale(1); }
75%{ opacity:1; }
100%{ opacity:0; transform:translateY(-14px) scale(1); }
}
#hint{
position:fixed; bottom:26px; left:0; right:0; text-align:center; opacity:0; pointer-events:none; z-index:5;
font-weight:600; font-size:15px; letter-spacing:.4em; color:#6fa8c9;
}
/* ---------- screens ---------- */
.overlay{
position:fixed; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:18px;
background:rgba(4,6,15,.66); backdrop-filter:blur(7px); z-index:10;
opacity:0; pointer-events:none; transition:opacity .45s;
}
.overlay.show{ opacity:1; pointer-events:auto; }
#scrTitle{ font-family:'Orbitron',monospace; font-weight:900; font-size:clamp(44px,8vw,88px); letter-spacing:.18em; padding-left:.18em; }
.overlay.show #scrTitle{ animation:titleGlow 3s ease-in-out infinite; }
@keyframes titleGlow{ 0%,100%{ filter:brightness(1); } 50%{ filter:brightness(1.3); } }
#scrSub{ font-size:17px; letter-spacing:.5em; padding-left:.5em; text-transform:uppercase; color:#7fb6d8; }
#scrStats{ display:flex; gap:34px; margin-top:8px; }
.stat{ text-align:center; }
.stat span{ display:block; font-size:12px; letter-spacing:.34em; color:#5f8fb8; margin-bottom:4px; }
.stat b{ font-family:'Orbitron',monospace; font-size:26px; color:#eaffff; text-shadow:0 0 14px rgba(0,229,255,.6); }
#scrPrompt{
margin-top:26px; font-family:'Orbitron',monospace; font-weight:700; font-size:18px; letter-spacing:.3em;
color:#9fefff; animation:pulse 1.6s ease-in-out infinite;
}
#scrPrompt.danger{ color:#ff9dbb; }
@keyframes pulse{ 0%,100%{ opacity:.35; } 50%{ opacity:1; } }
</style>
</head>
<body>
<div id="hud">
<div id="scoreBox">
<div class="label">SCORE</div>
<div id="score">0</div>
<div id="comboWrap">
<div id="comboX">x2</div>
<div id="comboBarOuter"><div id="comboBar"></div></div>
</div>
</div>
<div id="levelChip">LVL 1</div>
<div id="lives"></div>
</div>
<div id="vignette"></div>
<div id="scanlines"></div>
<div id="flash"></div>
<div id="msg"></div>
<div id="hint">W A S D &nbsp;&middot;&nbsp; MOVE</div>
<div id="screen" class="overlay">
<div id="scrTitle">NEON ARENA</div>
<div id="scrSub">collect the light &middot; survive the swarm</div>
<div id="scrStats"></div>
<div id="scrPrompt">PRESS ANY KEY TO START</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/three@0.152.2/build/three.min.js"></script>
<script>
(() => {
'use strict';
/* ============================ helpers ============================ */
const TAU = Math.PI * 2;
const rand = (a, b) => a + Math.random() * (b - a);
const randInt = (a, b) => Math.floor(rand(a, b + 1));
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const lerp = (a, b, t) => a + (b - a) * t;
const damp = (a, b, l, dt) => lerp(a, b, 1 - Math.exp(-l * dt));
const smooth = (a, b, x) => { x = clamp((x - a) / (b - a), 0, 1); return x * x * (3 - 2 * x); };
const easeOutBack = t => { const c = 1.70158; return 1 + (c + 1) * Math.pow(t - 1, 3) + c * Math.pow(t - 1, 2); };
const dampAngle = (a, b, l, dt) => {
let d = (b - a) % TAU;
if (d > Math.PI) d -= TAU;
if (d < -Math.PI) d += TAU;
return a + d * (1 - Math.exp(-l * dt));
};
/* ============================ config ============================ */
const ARENA = 22; // arena half-size
const PLAYER_ACC = 62; // accel units/s^2
const PLAYER_DRAG = 4.2; // exponential drag
const PLAYER_MAX_SPD = 13.5;
const START_LIVES = 3;
const UP = new THREE.Vector3(0, 1, 0);
/* ============================ renderer / scene ============================ */
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.15;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x05060f);
scene.fog = new THREE.Fog(0x070a16, 26, 95);
const camera = new THREE.PerspectiveCamera(58, window.innerWidth / window.innerHeight, 0.1, 700);
camera.position.set(0, 7, 14);
/* ============================ lights ============================ */
scene.add(new THREE.HemisphereLight(0x4466aa, 0x0a0a18, 0.55));
const sun = new THREE.DirectionalLight(0x99bbff, 1.15);
sun.position.set(14, 22, 10);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.left = -30; sun.shadow.camera.right = 30;
sun.shadow.camera.top = 30; sun.shadow.camera.bottom = -30;
sun.shadow.camera.near = 2; sun.shadow.camera.far = 70;
sun.shadow.bias = -0.0004;
sun.shadow.normalBias = 0.02;
scene.add(sun);
const rim = new THREE.PointLight(0xff2d78, 0.5, 60, 2);
rim.position.set(-16, 8, -14);
scene.add(rim);
/* ============================ canvas textures ============================ */
function makeCanvas(w, h, fn) {
const c = document.createElement('canvas');
c.width = w; c.height = h;
fn(c.getContext('2d'), w, h);
return c;
}
const glowTex = new THREE.CanvasTexture(makeCanvas(128, 128, (x) => {
const g = x.createRadialGradient(64, 64, 0, 64, 64, 64);
g.addColorStop(0, 'rgba(255,255,255,1)');
g.addColorStop(0.3, 'rgba(255,255,255,0.5)');
g.addColorStop(1, 'rgba(255,255,255,0)');
x.fillStyle = g; x.fillRect(0, 0, 128, 128);
}));
const ringTex = new THREE.CanvasTexture(makeCanvas(256, 256, (x) => {
x.strokeStyle = 'rgba(255,255,255,0.9)';
x.lineWidth = 10;
x.shadowColor = 'rgba(255,255,255,1)';
x.shadowBlur = 22;
x.beginPath(); x.arc(128, 128, 96, 0, TAU); x.stroke();
x.shadowBlur = 0;
x.lineWidth = 3;
x.beginPath(); x.arc(128, 128, 96, 0, TAU); x.stroke();
}));
const groundTex = new THREE.CanvasTexture(makeCanvas(1024, 1024, (x, w, h) => {
x.fillStyle = '#0a0e1e'; x.fillRect(0, 0, w, h);
let g = x.createRadialGradient(512, 512, 0, 512, 512, 620);
g.addColorStop(0, 'rgba(35,50,95,0.5)');
g.addColorStop(1, 'rgba(8,10,24,0)');
x.fillStyle = g; x.fillRect(0, 0, w, h);
x.strokeStyle = 'rgba(0,220,255,0.055)'; x.lineWidth = 2;
for (let i = 0; i <= w; i += 32) { x.beginPath(); x.moveTo(i, 0); x.lineTo(i, h); x.moveTo(0, i); x.lineTo(w, i); x.stroke(); }
x.strokeStyle = 'rgba(0,220,255,0.13)'; x.lineWidth = 3;
for (let i = 0; i <= w; i += 128) { x.beginPath(); x.moveTo(i, 0); x.lineTo(i, h); x.moveTo(0, i); x.lineTo(w, i); x.stroke(); }
x.strokeStyle = 'rgba(0,229,255,0.28)'; x.lineWidth = 4;
x.beginPath(); x.arc(512, 512, 86, 0, TAU); x.stroke();
x.setLineDash([20, 16]); x.lineWidth = 3; x.strokeStyle = 'rgba(0,229,255,0.22)';
x.beginPath(); x.arc(512, 512, 132, 0, TAU); x.stroke();
x.setLineDash([]);
x.strokeStyle = 'rgba(0,229,255,0.3)'; x.lineWidth = 4;
for (let a = 0; a < 4; a++) {
const ang = a * Math.PI / 2, c1 = Math.cos(ang), s1 = Math.sin(ang);
x.beginPath(); x.moveTo(512 + c1 * 140, 512 + s1 * 140); x.lineTo(512 + c1 * 180, 512 + s1 * 180); x.stroke();
}
g = x.createRadialGradient(512, 512, 300, 512, 512, 724);
g.addColorStop(0, 'rgba(2,3,10,0)');
g.addColorStop(1, 'rgba(2,3,10,0.9)');
x.fillStyle = g; x.fillRect(0, 0, w, h);
for (let i = 0; i < 500; i++) {
x.fillStyle = 'rgba(170,210,255,' + (Math.random() * 0.07).toFixed(3) + ')';
x.fillRect(Math.random() * w, Math.random() * h, rand(1, 2.4), rand(1, 2.4));
}
}));
groundTex.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy());
groundTex.colorSpace = THREE.SRGBColorSpace;
/* ============================ sky / stars / moon ============================ */
scene.add(new THREE.Mesh(
new THREE.SphereGeometry(300, 32, 16),
new THREE.ShaderMaterial({
side: THREE.BackSide, depthWrite: false, fog: false,
uniforms: {
cTop: { value: new THREE.Color(0x03040c) },
cMid: { value: new THREE.Color(0x1b1440) },
cBot: { value: new THREE.Color(0x05060f) }
},
vertexShader: `
varying vec3 vP;
void main(){ vP = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
fragmentShader: `
uniform vec3 cTop; uniform vec3 cMid; uniform vec3 cBot;
varying vec3 vP;
void main(){
float h = normalize(vP).y;
vec3 c = mix(cBot, cMid, smoothstep(-0.3, 0.12, h));
c = mix(c, cTop, smoothstep(0.12, 0.8, h));
c += cMid * 0.5 * exp(-abs(h - 0.02) * 10.0);
gl_FragColor = vec4(c, 1.0);
}`
})
));
const starMat = new THREE.PointsMaterial({
size: 1.8, sizeAttenuation: false, vertexColors: true,
transparent: true, opacity: 0.9, fog: false, depthWrite: false
});
{
const n = 380, pos = new Float32Array(n * 3), col = new Float32Array(n * 3);
const c = new THREE.Color();
for (let i = 0; i < n; i++) {
const th = rand(0, TAU), ph = Math.acos(rand(0.05, 1)), r = rand(240, 290);
pos[i * 3] = r * Math.sin(ph) * Math.cos(th);
pos[i * 3 + 1] = r * Math.cos(ph);
pos[i * 3 + 2] = r * Math.sin(ph) * Math.sin(th);
c.setHSL(rand(0.55, 0.75), rand(0.2, 0.7), rand(0.5, 0.95));
col[i * 3] = c.r; col[i * 3 + 1] = c.g; col[i * 3 + 2] = c.b;
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
g.setAttribute('color', new THREE.BufferAttribute(col, 3));
const stars = new THREE.Points(g, starMat);
stars.frustumCulled = false;
scene.add(stars);
}
function glowSprite(hex, opacity, scale, pos) {
const s = new THREE.Sprite(new THREE.SpriteMaterial({
map: glowTex, color: hex, transparent: true, opacity: opacity,
blending: THREE.AdditiveBlending, depthWrite: false, fog: false
}));
s.scale.setScalar(scale);
s.position.copy(pos);
scene.add(s);
return s;
}
const moonPos = new THREE.Vector3(-120, 95, -180);
glowSprite(0x8899ff, 0.55, 70, moonPos);
glowSprite(0xdde6ff, 0.9, 22, moonPos);
/* ============================ arena floor & boundary ============================ */
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(ARENA * 2, ARENA * 2),
new THREE.MeshStandardMaterial({ map: groundTex, roughness: 0.85, metalness: 0.25 })
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
const voidPlane = new THREE.Mesh(
new THREE.PlaneGeometry(600, 600),
new THREE.MeshBasicMaterial({ color: 0x04050c })
);
voidPlane.rotation.x = -Math.PI / 2;
voidPlane.position.y = -0.05;
scene.add(voidPlane);
const walls = [];
{
const wallGeo = new THREE.PlaneGeometry(ARENA * 2, 2.4);
[[0, -ARENA, 0], [0, ARENA, Math.PI], [-ARENA, 0, Math.PI / 2], [ARENA, 0, -Math.PI / 2]].forEach(([wx, wz, ry]) => {
const m = new THREE.Mesh(wallGeo, new THREE.MeshBasicMaterial({
color: 0x00e5ff, transparent: true, opacity: 0.08,
blending: THREE.AdditiveBlending, depthWrite: false, fog: false, side: THREE.DoubleSide
}));
m.position.set(wx, 1.2, wz);
m.rotation.y = ry;
scene.add(m);
walls.push(m);
});
}
const edgeMat = new THREE.MeshBasicMaterial({
color: 0x00e5ff, transparent: true, opacity: 0.5,
blending: THREE.AdditiveBlending, depthWrite: false, fog: false
});
function edgeBar(len, x, z, ry) {
const m = new THREE.Mesh(new THREE.BoxGeometry(len, 0.14, 0.3), edgeMat);
m.position.set(x, 0.07, z); m.rotation.y = ry;
scene.add(m);
}
edgeBar(ARENA * 2 + 0.6, 0, -ARENA, 0);
edgeBar(ARENA * 2 + 0.6, 0, ARENA, 0);
edgeBar(ARENA * 2 + 0.6, -ARENA, 0, Math.PI / 2);
edgeBar(ARENA * 2 + 0.6, ARENA, 0, Math.PI / 2);
const pillarMat = new THREE.MeshBasicMaterial({
color: 0xff2d78, transparent: true, opacity: 0.4,
blending: THREE.AdditiveBlending, depthWrite: false, fog: false
});
{
const pillarGeo = new THREE.BoxGeometry(0.5, 5, 0.5);
[[-1, -1], [-1, 1], [1, -1], [1, 1]].forEach(([sx, sz]) => {
const p = new THREE.Mesh(pillarGeo, pillarMat);
p.position.set(sx * ARENA, 2.5, sz * ARENA);
scene.add(p);
const s = new THREE.Sprite(new THREE.SpriteMaterial({
map: glowTex, color: 0xff2d78, transparent: true, opacity: 0.8,
blending: THREE.AdditiveBlending, depthWrite: false, fog: false
}));
s.position.set(sx * ARENA, 5.2, sz * ARENA);
s.scale.setScalar(2.5);
scene.add(s);
});
}
/* ============================ player ============================ */
const player = new THREE.Group();
const playerBody = new THREE.Group();
player.add(playerBody);
const playerMat = new THREE.MeshStandardMaterial({
color: 0x0a2a38, emissive: 0x00d9ff, emissiveIntensity: 1.1,
metalness: 0.7, roughness: 0.25, flatShading: true
});
const playerCore = new THREE.Mesh(new THREE.IcosahedronGeometry(0.5, 1), playerMat);
playerCore.castShadow = true;
playerBody.add(playerCore);
{
const eyeMat = new THREE.MeshBasicMaterial({ color: 0xeaffff, fog: false });
const eyeGeo = new THREE.SphereGeometry(0.07, 8, 8);
const eL = new THREE.Mesh(eyeGeo, eyeMat); eL.position.set(-0.16, 0.08, 0.42);
const eR = new THREE.Mesh(eyeGeo, eyeMat); eR.position.set( 0.16, 0.08, 0.42);
playerBody.add(eL, eR);
}
const ring1 = new THREE.Mesh(new THREE.TorusGeometry(0.8, 0.045, 10, 36),
new THREE.MeshBasicMaterial({ color: 0x9df8ff, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending, depthWrite: false, fog: false }));
const ring2 = new THREE.Mesh(new THREE.TorusGeometry(0.98, 0.03, 10, 36),
new THREE.MeshBasicMaterial({ color: 0x00e5ff, transparent: true, opacity: 0.45, blending: THREE.AdditiveBlending, depthWrite: false, fog: false }));
playerBody.add(ring1, ring2);
const playerGlow = new THREE.Sprite(new THREE.SpriteMaterial({
map: glowTex, color: 0x00e5ff, transparent: true, opacity: 0.5,
blending: THREE.AdditiveBlending, depthWrite: false, fog: false
}));
playerGlow.scale.setScalar(2.4);
playerBody.add(playerGlow);
const playerLight = new THREE.PointLight(0x00e5ff, 1.6, 18, 2);
playerLight.position.y = 0.4;
playerBody.add(playerLight);
const pool = new THREE.Mesh(new THREE.CircleGeometry(1.3, 32),
new THREE.MeshBasicMaterial({ color: 0x00b7d9, transparent: true, opacity: 0.16, blending: THREE.AdditiveBlending, depthWrite: false, fog: false }));
pool.rotation.x = -Math.PI / 2;
pool.position.y = 0.05;
player.add(pool);
const shield = new THREE.Mesh(new THREE.SphereGeometry(1.05, 24, 16), new THREE.ShaderMaterial({
transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, fog: false,
uniforms: { uOp: { value: 0 }, uTime: { value: 0 } },
vertexShader: `
varying vec3 vN; varying vec3 vV;
void main(){
vN = normalize(normalMatrix * normal);
vec4 mv = modelViewMatrix * vec4(position, 1.0);
vV = normalize(-mv.xyz);
gl_Position = projectionMatrix * mv;
}`,
fragmentShader: `
uniform float uOp; uniform float uTime;
varying vec3 vN; varying vec3 vV;
void main(){
float f = pow(1.0 - clamp(dot(vN, vV), 0.0, 1.0), 2.4);
float pulse = 0.75 + 0.25 * sin(uTime * 6.0);
vec3 col = mix(vec3(0.0, 0.85, 1.0), vec3(1.0), f * 0.7);
gl_FragColor = vec4(col, f * 0.85 * uOp * pulse + 0.06 * uOp);
}`
}));
shield.visible = false;
player.add(shield);
scene.add(player);
const pv = new THREE.Vector3(); // player velocity
let playerYaw = 0, playerPop = 0, invuln = 0, shieldTime = 0, bank = 0;
/* ============================ orb ============================ */
const orb = new THREE.Group();
const orbMat = new THREE.MeshStandardMaterial({
color: 0x03222e, emissive: 0x00e5ff, emissiveIntensity: 2.0, metalness: 0.3, roughness: 0.2
});
const orbMesh = new THREE.Mesh(new THREE.IcosahedronGeometry(0.38, 2), orbMat);
orb.add(orbMesh);
const orbRing = new THREE.Mesh(new THREE.TorusGeometry(0.62, 0.03, 8, 32),
new THREE.MeshBasicMaterial({ color: 0xaefcff, transparent: true, opacity: 0.8, blending: THREE.AdditiveBlending, depthWrite: false, fog: false }));
orb.add(orbRing);
const orbGlow = new THREE.Sprite(new THREE.SpriteMaterial({
map: glowTex, color: 0x00e5ff, transparent: true, opacity: 0.85,
blending: THREE.AdditiveBlending, depthWrite: false, fog: false
}));
orbGlow.scale.setScalar(3.2);
orb.add(orbGlow);
const orbLight = new THREE.PointLight(0x00e5ff, 0.9, 10, 2);
orb.add(orbLight);
scene.add(orb);
let orbGolden = false, orbSpawnT = 0;
function spawnOrb() {
let x = 0, z = 0;
for (let i = 0; i < 32; i++) {
const a = rand(0, TAU), r = rand(2.5, ARENA - 2.5);
x = Math.cos(a) * r; z = Math.sin(a) * r;
if (Math.hypot(x - player.position.x, z - player.position.z) > 7) break;
}
orb.position.set(x, 0, z);
orbGolden = Math.random() < 0.12;
const col = orbGolden ? 0xffc84d : 0x00e5ff;
orbMat.emissive.setHex(col);
orbMat.color.setHex(orbGolden ? 0x2a1a03 : 0x03222e);
orbGlow.material.color.setHex(col);
orbLight.color.setHex(col);
orbRing.material.color.setHex(orbGolden ? 0xffe6a8 : 0xaefcff);
orb.scale.setScalar(0.01);
orbSpawnT = gameTime;
}
function animateOrb(dt) {
const st = (gameTime - orbSpawnT) / 0.35;
orb.scale.setScalar(st < 1 ? Math.max(0.01, easeOutBack(clamp(st, 0, 1))) : 1);
orb.position.y = 1.05 + Math.sin(gameTime * 2.4) * 0.18;
orbRing.rotation.x += dt * 1.4;
orbRing.rotation.y += dt * 2.1;
orbMesh.rotation.y += dt * 1.2;
const p = 0.75 + 0.2 * Math.sin(gameTime * 4.5);
orbGlow.material.opacity = orbGolden ? 0.85 : p * 0.8;
orbLight.intensity = (orbGolden ? 1.4 : 0.9) * (0.8 + 0.4 * Math.sin(gameTime * 4.5));
}
/* ============================ enemies ============================ */
const ENEMY_MAX = 14;
const enemies = [];
{
const eBodyGeo = new THREE.IcosahedronGeometry(0.55, 0);
const eCoreGeo = new THREE.IcosahedronGeometry(0.3, 1);
const eEyeGeo = new THREE.SphereGeometry(0.075, 8, 8);
const eRingGeo = new THREE.TorusGeometry(0.78, 0.035, 8, 28);
const eEyeMat = new THREE.MeshBasicMaterial({ color: 0xfff5f5, fog: false });
for (let i = 0; i < ENEMY_MAX; i++) {
const g = new THREE.Group();
const body = new THREE.Mesh(eBodyGeo, new THREE.MeshStandardMaterial({
color: 0x1c050c, emissive: 0xff2d4d, emissiveIntensity: 0.85,
flatShading: true, metalness: 0.4, roughness: 0.35
}));
body.castShadow = true;
const core = new THREE.Mesh(eCoreGeo, new THREE.MeshBasicMaterial({
color: 0xff7788, transparent: true, opacity: 0.9,
blending: THREE.AdditiveBlending, depthWrite: false, fog: false
}));
const glow = new THREE.Sprite(new THREE.SpriteMaterial({
map: glowTex, color: 0xff2d55, transparent: true, opacity: 0.5,
blending: THREE.AdditiveBlending, depthWrite: false, fog: false
}));
glow.scale.setScalar(2.8);
const eyeL = new THREE.Mesh(eEyeGeo, eEyeMat); eyeL.position.set(-0.17, 0.12, 0.4);
const eyeR = new THREE.Mesh(eEyeGeo, eEyeMat); eyeR.position.set( 0.17, 0.12, 0.4);
const ring = new THREE.Mesh(eRingGeo, new THREE.MeshBasicMaterial({
color: 0xff4466, transparent: true, opacity: 0.5,
blending: THREE.AdditiveBlending, depthWrite: false, fog: false
}));
ring.rotation.x = Math.PI / 2.3;
g.add(body, core, glow, eyeL, eyeR, ring);
g.visible = false;
g.position.y = 0.75;
scene.add(g);
enemies.push({
g, body, core, ring, glow,
vel: new THREE.Vector3(),
active: false, phase: rand(0, TAU), spawnT: 0, speed: 4
});
}
}
function spawnEnemy() {
const e = enemies.find(en => !en.active);
if (!e) return;
const side = randInt(0, 3);
const t = rand(-ARENA + 1.5, ARENA - 1.5);
const off = ARENA - 0.9;
const p = e.g.position;
if (side === 0) { p.x = t; p.z = -off; }
else if (side === 1) { p.x = t; p.z = off; }
else if (side === 2) { p.x = -off; p.z = t; }
else { p.x = off; p.z = t; }
p.y = 0.75;
e.vel.set(0, 0, 0);
e.active = true;
e.spawnT = gameTime;
e.speed = Math.min(10.5, 3.3 + 0.5 * (level - 1) + rand(-0.3, 0.6));
e.g.visible = true;
e.g.scale.setScalar(0.01);
shockwave(p, 0xff2d55, 7);
burst(p, 0xff3355, 12, { spMin: 1, spMax: 4, size: 0.5 });
}
/* ============================ particles ============================ */
const P_MAX = 900;
const pPos = new Float32Array(P_MAX * 3);
const pVel = new Float32Array(P_MAX * 3);
const pCol = new Float32Array(P_MAX * 3);
const pSize = new Float32Array(P_MAX);
const pLife = new Float32Array(P_MAX);
const pMaxLife = new Float32Array(P_MAX).fill(1);
const pGrav = new Float32Array(P_MAX);
const pLifeA = new Float32Array(P_MAX);
let pCursor = 0;
const pGeo = new THREE.BufferGeometry();
pGeo.setAttribute('position', new THREE.BufferAttribute(pPos, 3).setUsage(THREE.DynamicDrawUsage));
pGeo.setAttribute('aColor', new THREE.BufferAttribute(pCol, 3).setUsage(THREE.DynamicDrawUsage));
pGeo.setAttribute('aSize', new THREE.BufferAttribute(pSize, 1).setUsage(THREE.DynamicDrawUsage));
const pLifeAttr = new THREE.BufferAttribute(pLifeA, 1).setUsage(THREE.DynamicDrawUsage);
pGeo.setAttribute('aLife', pLifeAttr);
const pMat = new THREE.ShaderMaterial({
transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
vertexShader: `
attribute vec3 aColor; attribute float aSize; attribute float aLife;
varying vec3 vC; varying float vL;
void main(){
vC = aColor; vL = aLife;
vec4 mv = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = aSize * (0.35 + 0.65 * aLife) * (240.0 / -mv.z);
gl_Position = projectionMatrix * mv;
}`,
fragmentShader: `
varying vec3 vC; varying float vL;
void main(){
float d = length(gl_PointCoord - 0.5);
float a = smoothstep(0.5, 0.05, d) * vL;
if (a < 0.02) discard;
gl_FragColor = vec4(vC, a);
}`
});
const points = new THREE.Points(pGeo, pMat);
points.frustumCulled = false;
scene.add(points);
const _burstColor = new THREE.Color();
function burst(pos, hex, n, o) {
o = o || {};
_burstColor.setHex(hex);
for (let i = 0; i < n; i++) {
const idx = (pCursor++) % P_MAX;
const th = rand(0, TAU);
const ph = Math.acos(clamp(rand(0.15, 1), -1, 1));
const sx = Math.sin(ph) * Math.cos(th), sy = Math.cos(ph), sz = Math.sin(ph) * Math.sin(th);
const sp = rand(o.spMin !== undefined ? o.spMin : 2, o.spMax !== undefined ? o.spMax : 6.5);
pPos[idx * 3] = pos.x + rand(-0.15, 0.15);
pPos[idx * 3 + 1] = pos.y + rand(-0.15, 0.15);
pPos[idx * 3 + 2] = pos.z + rand(-0.15, 0.15);
pVel[idx * 3] = sx * sp + (o.vx || 0);
pVel[idx * 3 + 1] = sy * sp + (o.vy || 0);
pVel[idx * 3 + 2] = sz * sp + (o.vz || 0);
const life = rand(o.lifeMin !== undefined ? o.lifeMin : 0.45, o.lifeMax !== undefined ? o.lifeMax : 0.9);
pLife[idx] = life;
pMaxLife[idx] = life;
pGrav[idx] = o.g !== undefined ? o.g : 7;
pSize[idx] = rand(0.3, 0.62) * (o.size !== undefined ? o.size : 1);
const v = rand(0.8, 1.2);
pCol[idx * 3] = clamp(_burstColor.r * v, 0, 1);
pCol[idx * 3 + 1] = clamp(_burstColor.g * v, 0, 1);
pCol[idx * 3 + 2] = clamp(_burstColor.b * v, 0, 1);
}
}
function clearParticles() { pLife.fill(0); pLifeA.fill(0); }
function updateParticles(dt) {
const drag = Math.exp(-2.0 * dt);
for (let i = 0; i < P_MAX; i++) {
if (pLife[i] <= 0) { pLifeA[i] = 0; continue; }
pLife[i] -= dt;
if (pLife[i] <= 0) { pLifeA[i] = 0; continue; }
pVel[i * 3 + 1] -= pGrav[i] * dt;
pVel[i * 3] *= drag;
pVel[i * 3 + 1] *= drag;
pVel[i * 3 + 2] *= drag;
let x = pPos[i * 3] + pVel[i * 3] * dt;
let y = pPos[i * 3 + 1] + pVel[i * 3 + 1] * dt;
let z = pPos[i * 3 + 2] + pVel[i * 3 + 2] * dt;
if (y < 0.03 && pVel[i * 3 + 1] < 0) {
y = 0.03;
pVel[i * 3 + 1] *= -0.45;
pVel[i * 3] *= 0.7;
pVel[i * 3 + 2] *= 0.7;
}
pPos[i * 3] = x; pPos[i * 3 + 1] = y; pPos[i * 3 + 2] = z;
pLifeA[i] = pLife[i] / pMaxLife[i];
}
pGeo.attributes.position.needsUpdate = true;
pGeo.attributes.aColor.needsUpdate = true;
pGeo.attributes.aSize.needsUpdate = true;
pLifeAttr.needsUpdate = true;
}
/* ============================ shockwaves ============================ */
const waves = [];
for (let i = 0; i < 6; i++) {
const m = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.MeshBasicMaterial({
map: ringTex, transparent: true, opacity: 0,
blending: THREE.AdditiveBlending, depthWrite: false, fog: false, side: THREE.DoubleSide
}));
m.rotation.x = -Math.PI / 2;
m.visible = false;
m.position.y = 0.15;
scene.add(m);
waves.push({ m, t: 1, max: 8 });
}
let wCursor = 0;
function shockwave(pos, hex, maxScale) {
const w = waves[(wCursor++) % waves.length];
w.m.position.set(pos.x, 0.15, pos.z);
w.m.material.color.setHex(hex);
w.m.visible = true;
w.t = 0;
w.max = maxScale || 8;
}
function updateWaves(dt) {
for (const w of waves) {
if (!w.m.visible) continue;
w.t += dt * 2.6;
if (w.t >= 1) { w.m.visible = false; continue; }
const s = 0.6 + w.t * w.max;
w.m.scale.set(s, s, 1);
w.m.material.opacity = (1 - w.t) * 0.85;
}
}
/* ============================ score popups ============================ */
const popups = [];
for (let i = 0; i < 6; i++) {
const cv = document.createElement('canvas');
cv.width = 256; cv.height = 96;
const tex = new THREE.CanvasTexture(cv);
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, opacity: 0, depthWrite: false, fog: false }));
sp.visible = false;
sp.scale.set(3.4, 1.28, 1);
scene.add(sp);
popups.push({ cv, ctx: cv.getContext('2d'), tex, sp, t: 1 });
}
let popCursor = 0;
const _popupColor = new THREE.Color();
function popup(pos, text, hex) {
const p = popups[(popCursor++) % popups.length];
const x = p.ctx;
x.clearRect(0, 0, 256, 96);
x.font = '700 40px Orbitron, sans-serif';
x.textAlign = 'center';
x.textBaseline = 'middle';
_popupColor.setHex(hex);
x.shadowColor = '#' + _popupColor.getHexString();
x.shadowBlur = 18;
x.fillStyle = '#ffffff';
x.fillText(text, 128, 50);
p.tex.needsUpdate = true;
p.sp.position.set(pos.x, pos.y + 1.3, pos.z);
p.sp.visible = true;
p.sp.material.opacity = 1;
p.t = 0;
}
function updatePopups(dt) {
for (const p of popups) {
if (!p.sp.visible) continue;
p.t += dt * 1.15;
if (p.t >= 1) { p.sp.visible = false; continue; }
p.sp.position.y += dt * 2.2;
p.sp.material.opacity = 1 - p.t * p.t;
const s = 1 + 0.25 * Math.sin(p.t * Math.PI);
p.sp.scale.set(3.4 * s, 1.28 * s, 1);
}
}
/* ============================ ambient dust ============================ */
const DUST = 130;
const dustPos = new Float32Array(DUST * 3);
const dustVel = [];
for (let i = 0; i < DUST; i++) {
dustPos[i * 3] = rand(-ARENA, ARENA);
dustPos[i * 3 + 1] = rand(0.2, 9);
dustPos[i * 3 + 2] = rand(-ARENA, ARENA);
dustVel.push(new THREE.Vector3(rand(-0.2, 0.2), rand(-0.25, -0.05), rand(-0.2, 0.2)));
}
const dustGeo = new THREE.BufferGeometry();
dustGeo.setAttribute('position', new THREE.BufferAttribute(dustPos, 3).setUsage(THREE.DynamicDrawUsage));
const dust = new THREE.Points(dustGeo, new THREE.PointsMaterial({
map: glowTex, size: 0.14, transparent: true, opacity: 0.3,
blending: THREE.AdditiveBlending, depthWrite: false, color: 0x5f8cff, fog: false
}));
dust.frustumCulled = false;
scene.add(dust);
function updateDust(dt) {
for (let i = 0; i < DUST; i++) {
dustPos[i * 3] += dustVel[i].x * dt;
dustPos[i * 3 + 1] += dustVel[i].y * dt;
dustPos[i * 3 + 2] += dustVel[i].z * dt;
if (dustPos[i * 3 + 1] < 0.1) dustPos[i * 3 + 1] = 9;
if (Math.abs(dustPos[i * 3]) > ARENA + 2) dustVel[i].x *= -1;
if (Math.abs(dustPos[i * 3 + 2]) > ARENA + 2) dustVel[i].z *= -1;
}
dustGeo.attributes.position.needsUpdate = true;
}
/* ============================ audio (synth) ============================ */
const Sfx = {
ctx: null, master: null, noiseBuf: null,
init() {
if (this.ctx) return;
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
this.ctx = new AC();
const comp = this.ctx.createDynamicsCompressor();
comp.threshold.value = -18; comp.knee.value = 20; comp.ratio.value = 8;
this.master = this.ctx.createGain();
this.master.gain.value = 0.55;
this.master.connect(comp);
comp.connect(this.ctx.destination);
const len = this.ctx.sampleRate | 0;
this.noiseBuf = this.ctx.createBuffer(1, len, this.ctx.sampleRate);
const d = this.noiseBuf.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
// ambient pad
const g = this.ctx.createGain();
g.gain.value = 0;
g.gain.linearRampToValueAtTime(0.05, this.ctx.currentTime + 3);
const filt = this.ctx.createBiquadFilter();
filt.type = 'lowpass'; filt.frequency.value = 220; filt.Q.value = 0.8;
[55, 82.41].forEach((f, i) => {
const o = this.ctx.createOscillator();
o.type = i ? 'triangle' : 'sine';
o.frequency.value = f;
o.detune.value = i ? 5 : 0;
const og = this.ctx.createGain();
og.gain.value = i ? 0.4 : 1;
o.connect(og); og.connect(filt);
o.start();
});
const lfo = this.ctx.createOscillator();
lfo.frequency.value = 0.07;
const lg = this.ctx.createGain();
lg.gain.value = 90;
lfo.connect(lg); lg.connect(filt.frequency);
lfo.start();
filt.connect(g); g.connect(this.master);
},
resume() { if (this.ctx && this.ctx.state === 'suspended') this.ctx.resume(); },
tone(o) {
if (!this.ctx) return;
const t0 = this.ctx.currentTime + (o.at || 0);
const dur = o.dur || 0.2;
const osc = this.ctx.createOscillator();
osc.type = o.type || 'sine';
osc.frequency.setValueAtTime(o.f0, t0);
if (o.f1) osc.frequency.exponentialRampToValueAtTime(Math.max(o.f1, 1), t0 + dur);
const g = this.ctx.createGain();
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(o.peak || 0.2, t0 + 0.008);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
osc.connect(g); g.connect(this.master);
osc.start(t0); osc.stop(t0 + dur + 0.05);
},
noise(o) {
if (!this.ctx) return;
const t0 = this.ctx.currentTime + (o.at || 0);
const dur = o.dur || 0.3;
const src = this.ctx.createBufferSource();
src.buffer = this.noiseBuf; src.loop = true;
const f = this.ctx.createBiquadFilter();
f.type = o.type || 'bandpass';
f.frequency.setValueAtTime(o.f0 || 600, t0);
if (o.f1) f.frequency.exponentialRampToValueAtTime(Math.max(o.f1, 1), t0 + dur);
f.Q.value = o.q || 0.8;
const g = this.ctx.createGain();
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(o.peak || 0.2, t0 + 0.01);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
src.connect(f); f.connect(g); g.connect(this.master);
src.start(t0); src.stop(t0 + dur + 0.05);
},
pickup(combo) {
const scale = [0, 2, 4, 7, 9, 12, 14, 16];
const semi = scale[clamp(combo - 1, 0, scale.length - 1)];
const f = 523.25 * Math.pow(2, semi / 12);
this.tone({ f0: f, f1: f, dur: 0.1, peak: 0.22, type: 'triangle' });
this.tone({ f0: f * 1.5, dur: 0.14, peak: 0.14, type: 'sine', at: 0.05 });
this.tone({ f0: f * 2, dur: 0.2, peak: 0.06, type: 'sine', at: 0.09 });
},
golden() {
[0, 4, 7, 12, 16].forEach((s, i) => this.tone({ f0: 659.25 * Math.pow(2, s / 12), dur: 0.16, peak: 0.16, type: 'triangle', at: i * 0.07 }));
this.tone({ f0: 1318.5, dur: 0.5, peak: 0.08, type: 'sine', at: 0.35 });
},
hurt() {
this.tone({ f0: 160, f1: 50, dur: 0.3, peak: 0.5, type: 'sawtooth' });
this.noise({ f0: 500, f1: 120, dur: 0.25, peak: 0.35 });
},
boom() {
this.noise({ f0: 900, f1: 90, dur: 0.4, peak: 0.4, q: 1.2 });
this.tone({ f0: 110, f1: 32, dur: 0.35, peak: 0.45, type: 'sine' });
},
level() {
[392, 523.25, 659.25].forEach((f, i) => this.tone({ f0: f, dur: 0.1, peak: 0.12, type: 'square', at: i * 0.07 }));
},
over() {
this.tone({ f0: 220, f1: 55, dur: 1.1, peak: 0.4, type: 'sawtooth' });
this.tone({ f0: 110, f1: 40, dur: 1.3, peak: 0.3, type: 'sine', at: 0.1 });
this.noise({ f0: 300, f1: 60, dur: 0.9, peak: 0.2, at: 0.05 });
},
start() {
this.tone({ f0: 523.25, dur: 0.09, peak: 0.18, type: 'triangle' });
this.tone({ f0: 784, dur: 0.12, peak: 0.18, type: 'triangle', at: 0.08 });
this.tone({ f0: 1046.5, dur: 0.2, peak: 0.12, type: 'sine', at: 0.16 });
}
};
/* ============================ HUD ============================ */
const $ = id => document.getElementById(id);
const elScore = $('score'), elLives = $('lives'), elLevel = $('levelChip');
const elCombo = $('comboWrap'), elComboX = $('comboX'), elComboBar = $('comboBar');
const elFlash = $('flash'), elMsg = $('msg'), elHint = $('hint');
const scr = $('screen'), scrTitle = $('scrTitle'), scrSub = $('scrSub');
const scrStats = $('scrStats'), scrPrompt = $('scrPrompt');
let best = 0;
try { best = +(localStorage.getItem('neon_arena_best') || 0); } catch (e) {}
function makeHeart() {
const ns = 'http://www.w3.org/2000/svg';
const s = document.createElementNS(ns, 'svg');
s.setAttribute('viewBox', '0 0 24 24');
const p = document.createElementNS(ns, 'path');
p.setAttribute('d', 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z');
s.appendChild(p);
return s;
}
function buildLives(n) {
elLives.innerHTML = '';
for (let i = 0; i < START_LIVES; i++) {
const h = makeHeart();
if (i >= n) h.classList.add('lost');
elLives.appendChild(h);
}
}
function loseLifeUI() {
const kids = [...elLives.children];
for (let i = kids.length - 1; i >= 0; i--) {
if (!kids[i].classList.contains('lost')) { kids[i].classList.add('lost'); return; }
}
}
function setScore(v) {
elScore.textContent = v;
elScore.classList.remove('pop');
void elScore.offsetWidth;
elScore.classList.add('pop');
}
function showMsg(text, color) {
elMsg.textContent = text;
elMsg.style.color = color;
elMsg.style.textShadow = '0 0 24px ' + color + ', 0 0 60px ' + color;
elMsg.classList.remove('show');
void elMsg.offsetWidth;
elMsg.classList.add('show');
}
function flash(color, opacity) {
elFlash.style.background = 'radial-gradient(ellipse at center, transparent 40%, ' + color + ' 100%)';
elFlash.style.transition = 'none';
elFlash.style.opacity = opacity;
requestAnimationFrame(() => {
elFlash.style.transition = 'opacity 0.5s ease-out';
elFlash.style.opacity = 0;
});
}
function fmtTime(t) {
const m = Math.floor(t / 60), s = Math.floor(t % 60);
return m + ':' + String(s).padStart(2, '0');
}
function showScreen(mode) {
scr.classList.add('show');
if (mode === 'menu') {
scrTitle.textContent = 'NEON ARENA';
scrTitle.style.color = '#7ff4ff';
scrTitle.style.textShadow = '0 0 30px rgba(0,229,255,.8), 0 0 90px rgba(0,229,255,.35)';
scrSub.textContent = 'collect the light \u00b7 survive the swarm';
scrStats.innerHTML = best > 0 ? '<div class="stat"><span>BEST</span><b>' + best + '</b></div>' : '';
scrPrompt.textContent = 'PRESS ANY KEY TO START';
scrPrompt.classList.remove('danger');
} else {
scrTitle.textContent = 'GAME OVER';
scrTitle.style.color = '#ff5c8a';
scrTitle.style.textShadow = '0 0 30px rgba(255,45,120,.8), 0 0 90px rgba(255,45,120,.35)';
scrSub.textContent = 'the swarm got you';
scrStats.innerHTML =
'<div class="stat"><span>FINAL SCORE</span><b>' + score + '</b></div>' +
'<div class="stat"><span>BEST</span><b>' + best + (score >= best && score > 0 ? ' \u2605' : '') + '</b></div>' +
'<div class="stat"><span>LEVEL</span><b>' + level + '</b></div>' +
'<div class="stat"><span>TIME</span><b>' + fmtTime(elapsed) + '</b></div>';
scrPrompt.textContent = 'PRESS ANY KEY TO RESTART';
scrPrompt.classList.add('danger');
}
}
function hideScreen() { scr.classList.remove('show'); }
/* ============================ game state ============================ */
let state = 'menu'; // menu | playing | dying | over
let score = 0, lives = START_LIVES, combo = 0, comboTimer = 0;
let elapsed = 0, level = 1, spawnTimer = 1.6;
let timeScale = 1, hitstopT = 0, hitstopS = 0.2, camShake = 0;
let gameTime = 0, dieTimer = 0, hintTimer = 0;
function hitstop(t, s) { hitstopT = Math.max(hitstopT, t); hitstopS = s; }
function resetGame() {
score = 0; lives = START_LIVES; combo = 0; comboTimer = 0;
elapsed = 0; level = 1; spawnTimer = 1.6;
pv.set(0, 0, 0);
player.position.set(0, 0.55, 0);
player.visible = true;
playerYaw = 0; player.rotation.y = 0;
playerBody.rotation.set(0, 0, 0);
playerBody.scale.setScalar(1);
invuln = 0; shieldTime = 0; playerPop = 0; bank = 0;
timeScale = 1; hitstopT = 0; camShake = 0;
shield.visible = false;
enemies.forEach(e => { e.active = false; e.g.visible = false; });
clearParticles();
popups.forEach(p => { p.sp.visible = false; });
waves.forEach(w => { w.m.visible = false; });
spawnOrb();
elScore.textContent = '0';
buildLives(lives);
elLevel.textContent = 'LVL 1';
elCombo.style.opacity = 0;
playerMat.emissiveIntensity = 1.1;
}
function startGame() {
resetGame();
state = 'playing';
hideScreen();
Sfx.start();
hintTimer = 6;
}
function damagePlayer(fromPos) {
if (invuln > 0 || state !== 'playing') return;
lives--;
loseLifeUI();
combo = 0; comboTimer = 0;
elCombo.style.opacity = 0;
invuln = 1.6;
const k = new THREE.Vector3().subVectors(player.position, fromPos).setY(0).normalize().multiplyScalar(15);
pv.add(k);
camShake = Math.max(camShake, 0.55);
hitstop(0.28, 0.12);
flash('rgba(255,45,90,0.55)', 0.55);
Sfx.hurt();
burst(player.position, 0xff3355, 26, { spMin: 3, spMax: 9 });
shockwave(player.position, 0xff3355, 6);
if (lives <= 0) die();
}
function die() {
state = 'dying';
dieTimer = 0;
Sfx.over();
camShake = 1.1;
hitstop(1.0, 0.18);
burst(player.position, 0x00e5ff, 60, { spMin: 2, spMax: 11, lifeMin: 0.6, lifeMax: 1.4 });
burst(player.position, 0xffffff, 20, { spMin: 1, spMax: 5 });
shockwave(player.position, 0x00e5ff, 12);
player.visible = false;
if (score > best) {
best = score;
try { localStorage.setItem('neon_arena_best', String(best)); } catch (e) {}
}
}
function pickupOrb() {
const base = orbGolden ? 50 : 10;
combo++;
comboTimer = 3.5;
const mult = clamp(combo, 1, 5);
const pts = base * mult;
score += pts;
setScore(score);
const pos = orb.position.clone();
const col = orbGolden ? 0xffc84d : 0x00e5ff;
popup(pos, orbGolden ? '+' + pts + ' SHIELD' : (mult > 1 ? '+' + pts + ' x' + mult : '+' + pts), col);
burst(pos, col, orbGolden ? 34 : 22, { spMin: 2, spMax: 8 });
shockwave(pos, col, orbGolden ? 10 : 6);
playerPop = 1;
camShake = Math.max(camShake, 0.08);
hitstop(0.1, 0.3);
if (orbGolden) {
shieldTime = 3.5;
showMsg('SHIELD', '#ffe6a8');
flash('rgba(255,200,77,0.35)', 0.3);
Sfx.golden();
} else {
Sfx.pickup(combo);
}
if (combo >= 2) {
elCombo.style.opacity = 1;
elComboX.textContent = 'x' + mult;
}
spawnOrb();
}
function updateLevel() {
const nl = 1 + Math.floor(elapsed / 18);
if (nl > level) {
level = nl;
elLevel.textContent = 'LVL ' + level;
elLevel.classList.remove('bump');
void elLevel.offsetWidth;
elLevel.classList.add('bump');
showMsg('LEVEL ' + level, '#c886ff');
Sfx.level();
shockwave(player.position, 0x9a5cff, 14);
}
}
/* ============================ updates ============================ */
function updateWalls() {
const pp = player.position;
const d = [pp.z + ARENA, ARENA - pp.z, pp.x + ARENA, ARENA - pp.x];
walls.forEach((w, i) => {
w.material.opacity = 0.06 + 0.03 * Math.sin(gameTime * 2 + i * 1.7) + smooth(7, 1.5, d[i]) * 0.5;
});
edgeMat.opacity = 0.4 + 0.15 * Math.sin(gameTime * 3);
pillarMat.opacity = 0.35 + 0.2 * Math.sin(gameTime * 2.4);
}
function updateMenu(dt) {
const t = gameTime;
camera.position.x = damp(camera.position.x, Math.sin(t * 0.12) * 13, 2.5, dt);
camera.position.y = damp(camera.position.y, 5.5 + Math.sin(t * 0.23) * 0.8, 2.5, dt);
camera.position.z = damp(camera.position.z, Math.cos(t * 0.12) * 13, 2.5, dt);
camera.fov = damp(camera.fov, 62, 2, dt);
camera.updateProjectionMatrix();
camera.lookAt(0, 1, 0);
player.position.y = 0.55 + Math.sin(t * 2) * 0.06;
playerBody.position.y = Math.sin(t * 3) * 0.05;
ring1.rotation.x += dt * 1.2;
ring2.rotation.z += dt * 0.9;
playerGlow.material.opacity = 0.45 + 0.15 * Math.sin(t * 5);
animateOrb(dt);
}
function updatePlaying(dt) {
elapsed += dt;
updateLevel();
const pp = player.position;
/* ---- camera-relative movement with momentum ---- */
const fwd = new THREE.Vector3().subVectors(pp, camera.position);
fwd.y = 0;
if (fwd.lengthSq() < 1e-6) fwd.set(0, 0, -1);
fwd.normalize();
const right = new THREE.Vector3().crossVectors(fwd, UP).normalize();
const ix = ((keys.KeyD || keys.ArrowRight) ? 1 : 0) - ((keys.KeyA || keys.ArrowLeft) ? 1 : 0);
const iz = ((keys.KeyW || keys.ArrowUp) ? 1 : 0) - ((keys.KeyS || keys.ArrowDown) ? 1 : 0);
const mv = new THREE.Vector3().addScaledVector(fwd, iz).addScaledVector(right, ix);
if (mv.lengthSq() > 0) mv.normalize();
pv.addScaledVector(mv, PLAYER_ACC * dt);
pv.multiplyScalar(Math.exp(-PLAYER_DRAG * dt));
const maxSpd = Math.min(15, PLAYER_MAX_SPD + level * 0.15);
const spd = pv.length();
if (spd > maxSpd) pv.multiplyScalar(maxSpd / spd);
pp.addScaledVector(pv, dt);
/* ---- arena bounds ---- */
const B = ARENA - 0.9;
if (Math.abs(pp.x) > B) {
pp.x = clamp(pp.x, -B, B);
if (Math.abs(pv.x) > 5) {
burst(pp, 0x00e5ff, 6, { spMin: 1, spMax: 3, size: 0.5 });
Sfx.noise({ f0: 1200, f1: 400, dur: 0.06, peak: 0.05 });
}
pv.x *= -0.3;
}
if (Math.abs(pp.z) > B) {
pp.z = clamp(pp.z, -B, B);
if (Math.abs(pv.z) > 5) {
burst(pp, 0x00e5ff, 6, { spMin: 1, spMax: 3, size: 0.5 });
Sfx.noise({ f0: 1200, f1: 400, dur: 0.06, peak: 0.05 });
}
pv.z *= -0.3;
}
pp.y = 0.55 + Math.sin(gameTime * 7.3) * 0.02;
/* ---- player visuals ---- */
if (spd > 0.4) playerYaw = dampAngle(playerYaw, Math.atan2(pv.x, pv.z), 10, dt);
player.rotation.y = playerYaw;
const lat = right.dot(pv);
playerBody.rotation.z = damp(playerBody.rotation.z, clamp(-lat * 0.03, -0.35, 0.35), 8, dt);
playerBody.rotation.x = damp(playerBody.rotation.x, -(spd / maxSpd) * 0.15, 6, dt);
playerBody.position.y = Math.sin(gameTime * (6 + spd * 0.6)) * 0.04 * (0.4 + spd / maxSpd);
ring1.rotation.x += dt * 2.2;
ring1.rotation.y += dt * 1.1;
ring2.rotation.z += dt * 1.6;
playerGlow.material.opacity = 0.45 + 0.15 * Math.sin(gameTime * 5) + (spd / maxSpd) * 0.25;
playerLight.intensity = 1.4 + (spd / maxSpd) * 1.2;
playerPop = Math.max(0, playerPop - dt * 2.6);
playerBody.scale.setScalar(1 + 0.3 * playerPop * playerPop);
if (invuln > 0) {
invuln -= dt;
playerMat.emissiveIntensity = 1.1 + 2.2 * Math.abs(Math.sin(gameTime * 18));
} else {
playerMat.emissiveIntensity = 1.1;
}
if (shieldTime > 0) {
shieldTime -= dt;
shield.visible = true;
shield.material.uniforms.uOp.value = clamp(shieldTime / 0.4, 0, 1);
shield.material.uniforms.uTime.value = gameTime;
shield.scale.setScalar(1 + 0.04 * Math.sin(gameTime * 9));
} else {
shield.visible = false;
}
/* ---- speed trail ---- */
if (spd > 7 && Math.random() < dt * 30) {
burst(new THREE.Vector3(pp.x + rand(-0.2, 0.2), 0.5, pp.z + rand(-0.2, 0.2)), 0x00c8ff, 1,
{ spMin: 0.2, spMax: 0.8, size: 0.6, lifeMin: 0.2, lifeMax: 0.45, g: 0 });
}
/* ---- combo timer ---- */
if (comboTimer > 0) {
comboTimer -= dt;
elComboBar.style.width = (comboTimer / 3.5 * 100) + '%';
if (comboTimer <= 0) {
combo = 0;
elCombo.style.opacity = 0;
}
}
/* ---- orb ---- */
animateOrb(dt);
{
const op = orb.position;
const d = new THREE.Vector3().subVectors(pp, op);
d.y = 0;
const dist = d.length();
if (dist < 5 && dist > 0.01) {
d.normalize();
op.addScaledVector(d, dt * 9); // magnet pull
}
if (Math.random() < dt * 6) {
burst(op, orbGolden ? 0xffc84d : 0x66f0ff, 1, { spMin: 0.3, spMax: 1.2, size: 0.5, lifeMin: 0.3, lifeMax: 0.6, g: 1 });
}
const st = (gameTime - orbSpawnT) / 0.35;
if (dist < 1.35 && st > 0.4) pickupOrb();
}
/* ---- enemy spawning (difficulty ramp) ---- */
spawnTimer -= dt;
const interval = Math.max(0.5, 2.3 - 0.18 * (level - 1));
const cap = Math.min(4 + level, 12);
if (spawnTimer <= 0) {
spawnTimer = interval * rand(0.8, 1.2);
const activeCount = enemies.reduce((n, e) => n + (e.active ? 1 : 0), 0);
if (activeCount < cap) spawnEnemy();
}
/* ---- enemies ---- */
for (const e of enemies) {
if (!e.active) continue;
const ep = e.g.position;
const to = new THREE.Vector3().subVectors(pp, ep);
to.y = 0;
const dist = to.length();
if (dist > 0.01) to.normalize();
const wob = Math.sin(gameTime * 2.2 + e.phase) * 0.35;
const wPerp = new THREE.Vector3(-to.z, 0, to.x).multiplyScalar(wob);
const dir = new THREE.Vector3().addVectors(to, wPerp).normalize();
e.vel.addScaledVector(dir, 26 * dt);
const s = e.vel.length();
if (s > e.speed) e.vel.multiplyScalar(e.speed / s);
ep.addScaledVector(e.vel, dt);
ep.x = clamp(ep.x, -ARENA + 0.3, ARENA - 0.3);
ep.z = clamp(ep.z, -ARENA + 0.3, ARENA - 0.3);
ep.y = 0.75 + Math.sin(gameTime * 5 + e.phase) * 0.06;
if (s > 0.3) e.g.rotation.y = dampAngle(e.g.rotation.y, Math.atan2(e.vel.x, e.vel.z), 8, dt);
const est = (gameTime - e.spawnT) / 0.3;
e.g.scale.setScalar(est < 1
? Math.max(0.01, easeOutBack(clamp(est, 0, 1)))
: 1 + 0.05 * Math.sin(gameTime * 6 + e.phase));
e.ring.rotation.z += dt * 3;
e.core.scale.setScalar(1 + 0.25 * Math.sin(gameTime * 7 + e.phase));
if (invuln <= 0) {
const cdist = new THREE.Vector3().subVectors(pp, ep).length();
if (cdist < 1.15) {
const pos = ep.clone();
e.active = false;
e.g.visible = false;
burst(pos, 0xff3355, 24, { spMin: 2, spMax: 8 });
shockwave(pos, 0xff5577, 7);
Sfx.boom();
damagePlayer(pos);
}
}
}
/* ---- enemy separation ---- */
const act = enemies.filter(e => e.active);
for (let i = 0; i < act.length; i++) {
for (let j = i + 1; j < act.length; j++) {
const a = act[i].g.position, b = act[j].g.position;
const dx = b.x - a.x, dz = b.z - a.z;
const d2 = dx * dx + dz * dz;
if (d2 < 2.25 && d2 > 0.0001) {
const d = Math.sqrt(d2), push = (1.5 - d) * 0.5;
const nx = dx / d, nz = dz / d;
a.x -= nx * push; a.z -= nz * push;
b.x += nx * push; b.z += nz * push;
}
}
}
/* ---- camera follow + juice ---- */
camera.position.x = damp(camera.position.x, pp.x, 4.5, dt);
camera.position.y = damp(camera.position.y, 5.6, 4.5, dt);
camera.position.z = damp(camera.position.z, pp.z + 9.6, 4.5, dt);
const look = new THREE.Vector3(
pp.x + clamp(pv.x * 0.3, -2.5, 2.5),
0.8 + pp.y * 0.4,
pp.z + clamp(pv.z * 0.3, -2.5, 2.5)
);
camera.lookAt(look);
bank = damp(bank, clamp(-lat * 0.014, -0.055, 0.055), 5, dt);
camera.rotateZ(bank);
if (camShake > 0) {
camShake = Math.max(0, camShake - dt * 2.2);
camera.position.x += rand(-1, 1) * camShake * 0.35;
camera.position.y += rand(-1, 1) * camShake * 0.3;
camera.position.z += rand(-1, 1) * camShake * 0.35;
}
const f = camera.fov;
camera.fov = damp(camera.fov, 58 + (spd / maxSpd) * 9, 4, dt);
if (Math.abs(camera.fov - f) > 0.001) camera.updateProjectionMatrix();
}
function updateDying(dt, rawDt) {
dieTimer += rawDt;
const a = gameTime * 0.5;
camera.position.x = damp(camera.position.x, player.position.x + Math.sin(a) * 10, 2, dt);
camera.position.y = damp(camera.position.y, 5, 2, dt);
camera.position.z = damp(camera.position.z, player.position.z + Math.cos(a) * 10, 2, dt);
camera.lookAt(player.position.x, 1, player.position.z);
if (dieTimer > 1.5) {
state = 'over';
showScreen('over');
}
}
function updateOver(dt) {
const a = gameTime * 0.15;
camera.position.x = damp(camera.position.x, player.position.x + Math.sin(a) * 11, 1.5, dt);
camera.position.y = damp(camera.position.y, 5.5, 1.5, dt);
camera.position.z = damp(camera.position.z, player.position.z + Math.cos(a) * 11, 1.5, dt);
camera.lookAt(player.position.x, 1, player.position.z);
}
/* ============================ input ============================ */
const keys = {};
const PREVENT = ['KeyW', 'KeyA', 'KeyS', 'KeyD', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space'];
window.addEventListener('keydown', e => {
if (PREVENT.includes(e.code)) e.preventDefault();
Sfx.init();
Sfx.resume();
if (e.repeat) return;
if (state === 'menu' || state === 'over') startGame();
keys[e.code] = true;
});
window.addEventListener('keyup', e => { keys[e.code] = false; });
window.addEventListener('blur', () => { for (const k in keys) keys[k] = false; });
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
/* ============================ main loop ============================ */
const clock = new THREE.Clock();
function loop() {
requestAnimationFrame(loop);
const rawDt = Math.min(clock.getDelta(), 0.05);
if (hitstopT > 0) {
hitstopT -= rawDt;
timeScale = hitstopS;
} else {
timeScale = damp(timeScale, 1, 10, rawDt);
}
const dt = rawDt * timeScale;
gameTime += dt;
if (state === 'menu') updateMenu(dt);
else if (state === 'playing') updatePlaying(dt);
else if (state === 'dying') updateDying(dt, rawDt);
else updateOver(dt);
updateParticles(dt);
updateWaves(dt);
updatePopups(dt);
updateDust(dt);
updateWalls();
starMat.opacity = 0.75 + 0.2 * Math.sin(gameTime * 0.8);
const ho = state === 'playing' && hintTimer > 0
? clamp(Math.min(1, (6 - hintTimer) * 2) * clamp(hintTimer * 0.8, 0, 1), 0, 1)
: 0;
if (state === 'playing' && hintTimer > 0) hintTimer -= rawDt;
elHint.style.opacity = ho;
renderer.render(scene, camera);
}
/* ============================ boot ============================ */
resetGame();
state = 'menu';
showScreen('menu');
loop();
})();
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EMBER ARENA — 3D Survival</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700;900&family=Chakra+Petch:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root{
--bg:#05090b; --line:rgba(62,224,198,.30);
--teal:#3ee6c4; --cyan:#4fd8ff; --gold:#ffcf6b; --gold2:#ffb347; --red:#ff3b57;
--ink:#d7ecec; --dim:#7fa3a6;
}
*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%;overflow:hidden;background:var(--bg);overscroll-behavior:none}
body{font-family:'Chakra Petch',system-ui,sans-serif;color:var(--ink);user-select:none;-webkit-user-select:none}
canvas{display:block}
#ui{position:fixed;inset:0;pointer-events:none;z-index:10}
/* ---------- overlays ---------- */
#vignette{position:absolute;inset:0;background:radial-gradient(ellipse at 50% 45%,transparent 52%,rgba(2,6,8,.6) 100%)}
#dmg{position:absolute;inset:0;background:radial-gradient(ellipse at center,transparent 32%,rgba(255,40,70,.65) 100%);opacity:0}
#lowlife{position:absolute;inset:0;box-shadow:inset 0 0 150px rgba(255,45,80,.55);opacity:0;transition:opacity .6s}
#lowlife.on{opacity:1;animation:lowPulse 1.1s ease-in-out infinite}
@keyframes lowPulse{50%{opacity:.35}}
/* ---------- HUD ---------- */
.panel{position:absolute;background:linear-gradient(180deg,rgba(14,26,31,.85),rgba(8,15,18,.88));
border:1px solid var(--line);padding:10px 16px 12px;
clip-path:polygon(10px 0,100% 0,100% calc(100% - 10px),calc(100% - 10px) 100%,0 100%,0 10px)}
.panel::after{content:'';position:absolute;inset:0;pointer-events:none;
background:repeating-linear-gradient(0deg,rgba(255,255,255,.025) 0 1px,transparent 1px 3px)}
.lbl{font-size:10px;letter-spacing:.34em;color:var(--teal);font-weight:600}
#scorePanel{top:18px;left:18px;min-width:172px}
#scoreVal{font-family:Orbitron;font-weight:900;font-size:42px;color:var(--gold);
text-shadow:0 0 18px rgba(255,180,70,.45);line-height:1.08}
#bestVal{font-size:11px;color:var(--dim);letter-spacing:.18em;margin-top:2px}
#scorePanel.bump{animation:bump .3s}
@keyframes bump{30%{transform:scale(1.06)}}
#livesPanel{top:18px;right:18px;text-align:right}
#livesPanel.shake{animation:hshake .4s}
@keyframes hshake{20%{transform:translateX(-5px)}40%{transform:translateX(5px)}60%{transform:translateX(-3px)}80%{transform:translateX(3px)}}
.hearts{display:flex;gap:8px;justify-content:flex-end;margin-top:7px}
.heart{width:26px;height:26px;color:var(--red);filter:drop-shadow(0 0 6px rgba(255,59,87,.7))}
.heart svg{width:100%;height:100%;fill:currentColor;display:block}
.heart.lost{animation:heartBreak .55s forwards}
@keyframes heartBreak{
0%{transform:scale(1)}
30%{transform:scale(1.5) rotate(-14deg);filter:drop-shadow(0 0 16px rgba(255,59,87,1))}
100%{transform:scale(.4) rotate(40deg);opacity:.15;filter:grayscale(1)}
}
#threatPanel{top:18px;left:50%;transform:translateX(-50%);text-align:center;min-width:250px}
#threatRow{display:flex;align-items:center;gap:12px;justify-content:center;margin-top:7px}
#threatVal{font-family:Orbitron;font-weight:700;font-size:19px;color:var(--ink)}
.segs{display:flex;gap:4px}
.seg{width:13px;height:8px;transform:skewX(-18deg);background:rgba(120,200,190,.10);border:1px solid rgba(120,200,190,.25)}
.seg.on{background:var(--gold);border-color:var(--gold);box-shadow:0 0 8px rgba(255,190,80,.6)}
.seg.hot{background:var(--red);border-color:var(--red);box-shadow:0 0 8px rgba(255,60,90,.6)}
#timeVal{font-family:Orbitron;font-size:13px;color:var(--dim);letter-spacing:.1em}
#dashPanel{bottom:18px;left:18px;display:flex;align-items:center;gap:10px}
.kbd{display:inline-block;font:600 11px 'Chakra Petch',sans-serif;letter-spacing:.12em;color:var(--ink);
background:#0b1519;border:1px solid rgba(140,220,215,.35);border-bottom-width:2px;padding:3px 7px;border-radius:3px}
.meter{width:120px;height:8px;background:rgba(90,200,220,.12);border:1px solid rgba(90,200,220,.3);transform:skewX(-18deg);overflow:hidden}
#dashFill{height:100%;width:100%;background:linear-gradient(90deg,var(--cyan),var(--teal));box-shadow:0 0 10px rgba(80,220,255,.5)}
#hint{position:absolute;bottom:22px;left:50%;transform:translateX(-50%);font-size:11px;letter-spacing:.24em;color:var(--dim);transition:opacity 1.2s}
#hint.fade{opacity:0}
#muteTag{position:absolute;bottom:22px;right:20px;font-size:11px;letter-spacing:.2em;color:var(--dim)}
#banner{position:absolute;top:100px;left:50%;transform:translateX(-50%);font-family:Orbitron;font-weight:700;
font-size:18px;letter-spacing:.32em;color:var(--red);text-shadow:0 0 14px rgba(255,60,90,.6);opacity:0;white-space:nowrap}
#banner.show{animation:banner 1.7s ease forwards}
@keyframes banner{0%{opacity:0;transform:translateX(-50%) translateY(-10px)}15%{opacity:1;transform:translateX(-50%)}75%{opacity:1}100%{opacity:0}}
.pop{position:absolute;left:0;top:0;font-family:Orbitron;font-weight:700;font-size:20px;color:var(--gold);
text-shadow:0 0 12px rgba(255,180,70,.85);white-space:nowrap;will-change:transform,opacity}
/* ---------- screens ---------- */
.screen{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;
gap:20px;padding:40px;text-align:center;
background:linear-gradient(180deg,rgba(3,8,10,.80),rgba(3,8,10,.48) 45%,rgba(3,8,10,.85));
opacity:0;visibility:hidden;transition:opacity .45s,visibility .45s}
.screen.on{opacity:1;visibility:visible}
.tag{font-size:12px;letter-spacing:.5em;color:var(--teal)}
.tag.red{color:var(--red);text-shadow:0 0 12px rgba(255,59,87,.5)}
.title{font-family:Orbitron;font-weight:900;font-size:clamp(50px,9.5vw,116px);letter-spacing:.05em;line-height:1;
color:var(--gold);text-shadow:0 0 30px rgba(255,170,60,.35),0 0 90px rgba(255,140,40,.18)}
.title span{color:transparent;-webkit-text-stroke:2px var(--teal);text-shadow:none;margin-left:.16em}
.title.small{font-size:clamp(38px,7vw,82px)}
.rule{display:flex;align-items:center;gap:14px;color:var(--gold);font-size:11px}
.rule i{display:block;width:min(220px,26vw);height:1px;background:linear-gradient(90deg,transparent,var(--gold))}
.rule i:last-child{background:linear-gradient(90deg,var(--gold),transparent)}
.sub{font-size:14px;line-height:1.75;letter-spacing:.12em;color:var(--dim)}
.controls{display:grid;grid-template-columns:auto auto;gap:12px 36px;margin-top:6px}
.controls div{display:flex;align-items:center;gap:7px;font-size:12px;letter-spacing:.22em;color:var(--dim)}
.controls em{font-style:normal;margin-left:8px}
.prompt{font-size:14px;letter-spacing:.3em;color:var(--ink);animation:blink 1.6s ease-in-out infinite;margin-top:6px}
@keyframes blink{50%{opacity:.35}}
.menuBest{position:absolute;bottom:26px;font-size:12px;letter-spacing:.3em;color:var(--dim)}
.final{display:flex;align-items:baseline;gap:18px;font-size:13px;letter-spacing:.34em;color:var(--dim);margin-top:8px}
.final b{font-family:Orbitron;font-weight:900;font-size:54px;color:var(--gold);letter-spacing:.04em;
text-shadow:0 0 24px rgba(255,180,70,.5)}
.newbest{font-family:Orbitron;font-weight:700;font-size:15px;letter-spacing:.4em;color:var(--teal);
text-shadow:0 0 16px rgba(62,230,196,.6);animation:blink 1.2s infinite;display:none}
.stats{display:flex;gap:14px;margin-top:4px}
.stat{background:linear-gradient(180deg,rgba(14,26,31,.85),rgba(8,15,18,.88));border:1px solid var(--line);
text-align:center;padding:12px 26px;clip-path:polygon(9px 0,100% 0,100% calc(100% - 9px),calc(100% - 9px) 100%,0 100%,0 9px)}
.stat b{font-family:Orbitron;font-weight:700;font-size:24px;display:block;color:var(--ink)}
.stat span{font-size:10px;letter-spacing:.3em;color:var(--dim)}
</style>
</head>
<body>
<div id="app"></div>
<div id="ui">
<div id="vignette"></div>
<div id="dmg"></div>
<div id="lowlife"></div>
<div id="hud">
<div class="panel" id="scorePanel">
<div class="lbl">SCORE</div>
<div id="scoreVal">00000</div>
<div id="bestVal">BEST 0</div>
</div>
<div class="panel" id="livesPanel">
<div class="lbl">LIVES</div>
<div class="hearts" id="hearts">
<span class="heart"><svg viewBox="0 0 24 24"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg></span>
<span class="heart"><svg viewBox="0 0 24 24"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg></span>
<span class="heart"><svg viewBox="0 0 24 24"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg></span>
</div>
</div>
<div class="panel" id="threatPanel">
<div class="lbl">THREAT</div>
<div id="threatRow">
<span id="threatVal">LV 1</span>
<span class="segs" id="segs"></span>
<span id="timeVal">0:00</span>
</div>
</div>
<div class="panel" id="dashPanel">
<span class="kbd">SHIFT</span><span class="lbl">DASH</span>
<div class="meter"><div id="dashFill"></div></div>
</div>
<div id="hint">W A S D MOVE &nbsp;·&nbsp; SHIFT DASH &nbsp;·&nbsp; M MUTE &nbsp;·&nbsp; P PAUSE</div>
<div id="muteTag">[M] MUTE</div>
<div id="banner"></div>
</div>
<!-- MENU -->
<div class="screen on" id="menuScreen">
<div class="tag">SECTOR 07 · CONTAINMENT FLOOR</div>
<h1 class="title">EMBER<span>ARENA</span></h1>
<div class="rule"><i></i>◆<i></i></div>
<p class="sub">Harvest the embers. Outrun the wraiths.<br>The arena tightens as the threat climbs.</p>
<div class="controls">
<div><span class="kbd">W</span><span class="kbd">A</span><span class="kbd">S</span><span class="kbd">D</span><em>MOVE</em></div>
<div><span class="kbd">SHIFT</span><em>DASH</em></div>
<div><span class="kbd">M</span><em>MUTE</em></div>
<div><span class="kbd">P</span><em>PAUSE</em></div>
</div>
<div class="prompt">PRESS <span class="kbd">ANY KEY</span> TO ENTER THE ARENA</div>
<div class="menuBest" id="menuBest"></div>
</div>
<!-- GAME OVER -->
<div class="screen" id="overScreen">
<div class="tag red">CONTAINMENT FAILED</div>
<h1 class="title small">SIGNAL<span>LOST</span></h1>
<div class="final">FINAL SCORE <b id="finalScore">00000</b></div>
<div class="newbest" id="newBest">◆ NEW BEST ◆</div>
<div class="stats">
<div class="stat"><b id="stTime">0:00</b><span>TIME</span></div>
<div class="stat"><b id="stOrbs">0</b><span>EMBERS</span></div>
<div class="stat"><b id="stThreat">LV 1</b><span>THREAT</span></div>
</div>
<div class="prompt">PRESS <span class="kbd">ANY KEY</span> TO RE-ENTER</div>
</div>
<!-- PAUSE -->
<div class="screen" id="pauseScreen">
<div class="tag">SYSTEMS HELD</div>
<h1 class="title small">PAUSED</h1>
<div class="prompt">PRESS <span class="kbd">P</span> TO RESUME</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
/* ============================================================
EMBER ARENA — single-file three.js arena survival
============================================================ */
'use strict';
/* ---------- helpers ---------- */
const $=s=>document.querySelector(s);
const rand=(a,b)=>a+Math.random()*(b-a);
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v));
const lerp=(a,b,t)=>a+(b-a)*t;
function lerpAngle(a,b,t){let d=(b-a)%(Math.PI*2);if(d>Math.PI)d-=Math.PI*2;if(d<-Math.PI)d+=Math.PI*2;return a+d*t;}
const fmt=s=>{const m=Math.floor(s/60),ss=Math.floor(s%60);return m+':'+String(ss).padStart(2,'0');};
/* ---------- config ---------- */
const ARENA=64, HALF=ARENA/2, BOUND=HALF-1.7;
const ACC=30, MAXS=10, DASH_CD=1.5;
const C_GOLD=0xffb347, C_CYAN=0x4fd8ff, C_RED=0xff3b57, C_TEAL=0x2fe8c8;
/* ---------- renderer / scene / camera ---------- */
const renderer=new THREE.WebGLRenderer({antialias:true,powerPreference:'high-performance'});
renderer.setPixelRatio(Math.min(devicePixelRatio,2));
renderer.setSize(innerWidth,innerHeight);
renderer.shadowMap.enabled=true;
renderer.shadowMap.type=THREE.PCFSoftShadowMap;
renderer.outputEncoding=THREE.sRGBEncoding;
renderer.toneMapping=THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure=1.15;
$('#app').appendChild(renderer.domElement);
const scene=new THREE.Scene();
scene.background=new THREE.Color(0x070d10);
scene.fog=new THREE.FogExp2(0x070d10,0.02);
const camera=new THREE.PerspectiveCamera(58,innerWidth/innerHeight,0.1,300);
/* ---------- procedural textures ---------- */
function glowTexture(){
const c=document.createElement('canvas');c.width=c.height=128;
const g=c.getContext('2d');
const gr=g.createRadialGradient(64,64,0,64,64,64);
gr.addColorStop(0,'rgba(255,255,255,1)');
gr.addColorStop(0.35,'rgba(255,255,255,0.35)');
gr.addColorStop(1,'rgba(255,255,255,0)');
g.fillStyle=gr;g.fillRect(0,0,128,128);
return new THREE.CanvasTexture(c);
}
const glowTex=glowTexture();
function makeGroundTexture(){
const S=2048,c=document.createElement('canvas');c.width=c.height=S;const g=c.getContext('2d');
const base=g.createRadialGradient(S/2,S/2,S*0.05,S/2,S/2,S*0.72);
base.addColorStop(0,'#152229');base.addColorStop(0.55,'#0f1a20');base.addColorStop(1,'#0a1114');
g.fillStyle=base;g.fillRect(0,0,S,S);
for(let i=0;i<12000;i++){g.fillStyle='rgba(140,200,205,'+(Math.random()*0.05)+')';g.fillRect(Math.random()*S,Math.random()*S,2,2);}
const step=S/16;
g.lineWidth=2;g.strokeStyle='rgba(84,196,186,0.10)';g.beginPath();
for(let i=0;i<=16;i++){g.moveTo(i*step,0);g.lineTo(i*step,S);g.moveTo(0,i*step);g.lineTo(S,i*step);}
g.stroke();
g.lineWidth=4;g.strokeStyle='rgba(96,220,206,0.16)';g.beginPath();
for(let i=0;i<=4;i++){g.moveTo(i*step*4,0);g.lineTo(i*step*4,S);g.moveTo(0,i*step*4);g.lineTo(S,i*step*4);}
g.stroke();
[[0,0],[S,0],[0,S],[S,S]].forEach(([x,y])=>{
const gr=g.createRadialGradient(x,y,0,x,y,S*0.22);
gr.addColorStop(0,'rgba(62,230,196,0.10)');gr.addColorStop(1,'rgba(62,230,196,0)');
g.fillStyle=gr;g.fillRect(0,0,S,S);
});
const cx=S/2,cy=S/2;
g.strokeStyle='rgba(255,190,90,0.30)';
g.lineWidth=5;g.beginPath();g.arc(cx,cy,S*0.085,0,7);g.stroke();
g.lineWidth=2;g.setLineDash([14,10]);g.beginPath();g.arc(cx,cy,S*0.115,0,7);g.stroke();g.setLineDash([]);
g.lineWidth=3;
for(let i=0;i<48;i++){const a=i/48*Math.PI*2,r1=S*0.085,r2=r1+(i%4===0?26:12);
g.beginPath();g.moveTo(cx+Math.cos(a)*r1,cy+Math.sin(a)*r1);g.lineTo(cx+Math.cos(a)*r2,cy+Math.sin(a)*r2);g.stroke();}
g.strokeStyle='rgba(255,190,90,0.35)';g.lineWidth=8;g.strokeRect(20,20,S-40,S-40);
g.strokeStyle='rgba(62,230,196,0.22)';g.lineWidth=2;g.strokeRect(34,34,S-68,S-68);
const t=new THREE.CanvasTexture(c);
t.encoding=THREE.sRGBEncoding;
t.anisotropy=renderer.capabilities.getMaxAnisotropy();
return t;
}
/* ---------- arena ---------- */
const ground=new THREE.Mesh(new THREE.PlaneGeometry(ARENA,ARENA),
new THREE.MeshStandardMaterial({map:makeGroundTexture(),roughness:0.85,metalness:0.25}));
ground.rotation.x=-Math.PI/2;ground.receiveShadow=true;scene.add(ground);
const voidPlane=new THREE.Mesh(new THREE.PlaneGeometry(600,600),new THREE.MeshBasicMaterial({color:0x05090b}));
voidPlane.rotation.x=-Math.PI/2;voidPlane.position.y=-0.02;scene.add(voidPlane);
const wallMat=new THREE.MeshStandardMaterial({color:0x111b21,roughness:0.6,metalness:0.5});
const stripMat=new THREE.MeshStandardMaterial({color:0x0a1518,emissive:0x2fe8c8,emissiveIntensity:1.1});
function addWall(w,d,x,z){
const m=new THREE.Mesh(new THREE.BoxGeometry(w,2.6,d),wallMat);
m.position.set(x,1.3,z);m.castShadow=m.receiveShadow=true;scene.add(m);
const s=new THREE.Mesh(new THREE.BoxGeometry(w+0.06,0.14,d+0.06),stripMat);
s.position.set(x,2.62,z);scene.add(s);
}
addWall(ARENA+2.4,1.2,0,-HALF-0.6);addWall(ARENA+2.4,1.2,0,HALF+0.6);
addWall(1.2,ARENA,-HALF-0.6,0);addWall(1.2,ARENA,HALF+0.6,0);
const crystals=[];
[[-1,-1],[1,-1],[-1,1],[1,1]].forEach(([sx,sz],i)=>{
const p=new THREE.Mesh(new THREE.BoxGeometry(2,4.4,2),wallMat);
p.position.set(sx*(HALF+0.6),2.2,sz*(HALF+0.6));p.castShadow=true;scene.add(p);
const cr=new THREE.Mesh(new THREE.OctahedronGeometry(0.55),
new THREE.MeshStandardMaterial({color:0x301a05,emissive:0xffb347,emissiveIntensity:1.6,roughness:0.3}));
cr.position.set(sx*(HALF+0.6),4.9,sz*(HALF+0.6));cr.castShadow=true;scene.add(cr);
const sp=new THREE.Sprite(new THREE.SpriteMaterial({map:glowTex,color:0xffb347,transparent:true,opacity:0.5,blending:THREE.AdditiveBlending,depthWrite:false}));
sp.scale.set(3,3,1);sp.position.copy(cr.position);scene.add(sp);
crystals.push({cr,sp,i});
});
const ringGrpA=new THREE.Group(),ringGrpB=new THREE.Group();scene.add(ringGrpA,ringGrpB);
function addRing(grp,r1,r2,color,op){
const m=new THREE.Mesh(new THREE.RingGeometry(r1,r2,64),
new THREE.MeshBasicMaterial({color,transparent:true,opacity:op,side:THREE.DoubleSide,blending:THREE.AdditiveBlending,depthWrite:false}));
m.rotation.x=-Math.PI/2;m.position.y=0.03;grp.add(m);
}
addRing(ringGrpA,7.4,7.7,0x2fe8c8,0.30);
addRing(ringGrpB,10.8,10.95,0xffb347,0.16);
/* ---------- lights ---------- */
scene.add(new THREE.HemisphereLight(0x2c4d55,0x0b0e10,0.6));
const sun=new THREE.DirectionalLight(0xcfe8f0,0.9);
sun.position.set(20,34,14);sun.castShadow=true;
sun.shadow.mapSize.set(2048,2048);
sun.shadow.camera.left=-40;sun.shadow.camera.right=40;
sun.shadow.camera.top=40;sun.shadow.camera.bottom=-40;
sun.shadow.camera.near=5;sun.shadow.camera.far=90;
sun.shadow.bias=-0.0004;
scene.add(sun);
/* ---------- player ---------- */
const player=new THREE.Group();scene.add(player);
const tilt=new THREE.Group();player.add(tilt);
const hullMat=new THREE.MeshStandardMaterial({color:0xdfe9ee,metalness:0.7,roughness:0.3,emissive:0x1a3a44,emissiveIntensity:0.4});
const hull=new THREE.Mesh(new THREE.SphereGeometry(0.55,32,24),hullMat);
hull.scale.set(1,0.72,1.35);hull.castShadow=true;tilt.add(hull);
const canopyMat=new THREE.MeshStandardMaterial({color:0x06222b,emissive:0x4fd8ff,emissiveIntensity:1.4,roughness:0.2,metalness:0.1});
const canopy=new THREE.Mesh(new THREE.SphereGeometry(0.3,24,16),canopyMat);
canopy.position.set(0,0.12,0.15);canopy.scale.set(1,0.8,1.3);tilt.add(canopy);
const ringGrp=new THREE.Group();tilt.add(ringGrp);
const ringP=new THREE.Mesh(new THREE.TorusGeometry(0.72,0.05,10,40),
new THREE.MeshStandardMaterial({color:0x0a1a20,emissive:0x3ee6c4,emissiveIntensity:1.2}));
ringP.rotation.x=Math.PI/2;ringP.position.y=-0.05;ringGrp.add(ringP);
const engine=new THREE.Sprite(new THREE.SpriteMaterial({map:glowTex,color:0x4fd8ff,transparent:true,opacity:0.9,blending:THREE.AdditiveBlending,depthWrite:false}));
engine.scale.set(1.4,1.4,1);engine.position.set(0,-0.05,-0.85);tilt.add(engine);
const pLight=new THREE.PointLight(0x38d8ff,0.85,11);pLight.position.y=0.6;player.add(pLight);
player.position.set(0,0.55,0);
const P={pos:new THREE.Vector3(0,0.55,0),vel:new THREE.Vector3(),heading:0,invuln:0};
/* ---------- orb ---------- */
const orbGrp=new THREE.Group();scene.add(orbGrp);
const orbFloat=new THREE.Group();orbGrp.add(orbFloat);
const orbCore=new THREE.Mesh(new THREE.SphereGeometry(0.42,24,18),
new THREE.MeshStandardMaterial({color:0x2b1603,emissive:0xffb347,emissiveIntensity:2.4,roughness:0.35}));
const orbInner=new THREE.Mesh(new THREE.SphereGeometry(0.2,16,12),new THREE.MeshBasicMaterial({color:0xfff3d0}));
const orbHalo=new THREE.Sprite(new THREE.SpriteMaterial({map:glowTex,color:0xffc46b,transparent:true,opacity:0.9,blending:THREE.AdditiveBlending,depthWrite:false}));
orbHalo.scale.set(4,4,1);
const orbSpin=new THREE.Group();
for(let i=0;i<3;i++){
const s=new THREE.Mesh(new THREE.TetrahedronGeometry(0.08),new THREE.MeshBasicMaterial({color:0xffe0a0}));
const a=i/3*Math.PI*2;
s.position.set(Math.cos(a)*0.85,Math.sin(a*2)*0.2,Math.sin(a)*0.85);
orbSpin.add(s);
}
const orbLight=new THREE.PointLight(0xffb347,1.5,13);
orbFloat.add(orbCore,orbInner,orbHalo,orbSpin,orbLight);
const orbGlow=new THREE.Mesh(new THREE.CircleGeometry(1.6,32),
new THREE.MeshBasicMaterial({color:0xff9d3d,transparent:true,opacity:0.2,blending:THREE.AdditiveBlending,depthWrite:false,side:THREE.DoubleSide}));
orbGlow.rotation.x=-Math.PI/2;orbGlow.position.y=0.05;orbGrp.add(orbGlow);
const orbBeam=new THREE.Mesh(new THREE.CylinderGeometry(0.05,0.14,1.2,8,1,true),
new THREE.MeshBasicMaterial({color:0xffb347,transparent:true,opacity:0.18,blending:THREE.AdditiveBlending,depthWrite:false,side:THREE.DoubleSide}));
orbBeam.position.y=0.6;orbGrp.add(orbBeam);
let orbFlash=0;
function spawnOrb(){
let x=0,z=0;
for(let i=0;i<24;i++){
const a=Math.random()*Math.PI*2,r=Math.sqrt(Math.random())*(BOUND-5);
x=Math.cos(a)*r;z=Math.sin(a)*r;
if((x-P.pos.x)**2+(z-P.pos.z)**2>49)break;
}
orbGrp.position.set(x,0,z);
}
/* ---------- enemies ---------- */
const enemyGeo=new THREE.IcosahedronGeometry(0.6,0);
const enemyMat=new THREE.MeshStandardMaterial({color:0x2a0a12,emissive:0xff2444,emissiveIntensity:1.5,flatShading:true,roughness:0.4,metalness:0.3});
const cageGeo=new THREE.IcosahedronGeometry(0.85,0);
const cageMat=new THREE.MeshBasicMaterial({color:0xff3b57,wireframe:true,transparent:true,opacity:0.35});
const enemyHaloMat=new THREE.SpriteMaterial({map:glowTex,color:0xff3b57,transparent:true,opacity:0.55,blending:THREE.AdditiveBlending,depthWrite:false});
const enemies=[];
function makeEnemy(){
const g=new THREE.Group();
const core=new THREE.Mesh(enemyGeo,enemyMat);core.castShadow=true;
const cage=new THREE.Mesh(cageGeo,cageMat);
const halo=new THREE.Sprite(enemyHaloMat);halo.scale.set(3.2,3.2,1);
g.add(core,cage,halo);scene.add(g);
return {g,core,cage,spawnT:0,phase:Math.random()*6.28,spdMul:rand(0.85,1.15)};
}
function spawnEnemy(x,z){
if(enemies.length>=26)return;
const e=makeEnemy();
e.g.position.set(x,1.05,z);e.g.scale.setScalar(0.001);
enemies.push(e);
}
function removeEnemy(i){
const e=enemies[i];
burst(e.g.position.x,e.g.position.y,e.g.position.z,16,C_RED,6,0.55,2.2,2,1);
scene.remove(e.g);
enemies.splice(i,1);
}
const markerPool=[];
function getMarker(){
let m=markerPool.find(m=>!m.visible);
if(!m){
m=new THREE.Mesh(new THREE.RingGeometry(0.55,0.8,32),
new THREE.MeshBasicMaterial({color:0xff3b57,transparent:true,opacity:0.8,side:THREE.DoubleSide,blending:THREE.AdditiveBlending,depthWrite:false}));
m.rotation.x=-Math.PI/2;m.position.y=0.06;m.visible=false;scene.add(m);markerPool.push(m);
}
m.visible=true;return m;
}
const pending=[];
function scheduleSpawn(){
const side=(Math.random()*4)|0,t=rand(-BOUND+2.5,BOUND-2.5);
let x,z;
if(side===0){x=t;z=-BOUND+0.6;}else if(side===1){x=t;z=BOUND-0.6;}
else if(side===2){x=-BOUND+0.6;z=t;}else{x=BOUND-0.6;z=t;}
const m=getMarker();m.position.set(x,0.06,z);
pending.push({x,z,t:0.55,m});
sfx.spawn();
}
/* ---------- particles ---------- */
const PMAX=900;
const pPos=new Float32Array(PMAX*3),pCol=new Float32Array(PMAX*3),pVel=new Float32Array(PMAX*3);
const pLife=new Float32Array(PMAX),pMax=new Float32Array(PMAX),pBase=new Float32Array(PMAX*3);
const pDrag=new Float32Array(PMAX),pGrav=new Float32Array(PMAX);
for(let i=0;i<PMAX;i++)pPos[i*3+1]=-999;
let pHead=0;
const pGeo=new THREE.BufferGeometry();
pGeo.setAttribute('position',new THREE.BufferAttribute(pPos,3).setUsage(THREE.DynamicDrawUsage));
pGeo.setAttribute('color',new THREE.BufferAttribute(pCol,3).setUsage(THREE.DynamicDrawUsage));
const pPoints=new THREE.Points(pGeo,new THREE.PointsMaterial({size:0.55,map:glowTex,vertexColors:true,transparent:true,depthWrite:false,blending:THREE.AdditiveBlending,sizeAttenuation:true}));
pPoints.frustumCulled=false;scene.add(pPoints);
function emit(x,y,z,vx,vy,vz,r,g,b,life,drag,grav){
const i=pHead;pHead=(pHead+1)%PMAX;const i3=i*3;
pPos[i3]=x;pPos[i3+1]=y;pPos[i3+2]=z;
pVel[i3]=vx;pVel[i3+1]=vy;pVel[i3+2]=vz;
pLife[i]=pMax[i]=life;
pBase[i3]=r;pBase[i3+1]=g;pBase[i3+2]=b;
pDrag[i]=drag;pGrav[i]=grav;
}
function burst(x,y,z,n,hex,spd,life,drag,grav,up){
const c=new THREE.Color(hex);
for(let i=0;i<n;i++){
const a=Math.random()*Math.PI*2,b=Math.acos(rand(-1,1));
const s=spd*rand(0.3,1);
emit(x+rand(-.2,.2),y+rand(-.2,.2),z+rand(-.2,.2),
Math.sin(b)*Math.cos(a)*s,Math.cos(b)*s*0.7+(up||0)*rand(0.3,1),Math.sin(b)*Math.sin(a)*s,
c.r*rand(0.8,1.2),c.g*rand(0.8,1.2),c.b*rand(0.8,1.2),
life*rand(0.6,1.3),drag,grav);
}
}
let trailAcc=0;
function emitTrail(dt){
if(P.vel.length()<1.6)return;
trailAcc+=dt;if(trailAcc<0.03)return;trailAcc=0;
const sh=Math.sin(P.heading),ch=Math.cos(P.heading);
const dashing=dashCd>DASH_CD-0.4;
emit(P.pos.x-sh*0.95+rand(-.15,.15),P.pos.y-0.15+rand(-.1,.1),P.pos.z-ch*0.95+rand(-.15,.15),
rand(-.5,.5),rand(-.2,.5),rand(-.5,.5),
dashing?1:0.31,dashing?0.7:0.85,dashing?0.25:1,
rand(.25,.45),2.5,0);
}
function updateParticles(dt){
for(let i=0;i<PMAX;i++){
if(pLife[i]<=0)continue;
pLife[i]-=dt;
const i3=i*3;
if(pLife[i]<=0){pPos[i3+1]=-999;pCol[i3]=pCol[i3+1]=pCol[i3+2]=0;continue;}
const dr=Math.exp(-pDrag[i]*dt);
pVel[i3]*=dr;pVel[i3+1]*=dr;pVel[i3+2]*=dr;
pVel[i3+1]-=pGrav[i]*dt;
pPos[i3]+=pVel[i3]*dt;pPos[i3+1]+=pVel[i3+1]*dt;pPos[i3+2]+=pVel[i3+2]*dt;
const f=pLife[i]/pMax[i],ff=f*f;
pCol[i3]=pBase[i3]*ff;pCol[i3+1]=pBase[i3+1]*ff;pCol[i3+2]=pBase[i3+2]*ff;
}
pGeo.attributes.position.needsUpdate=true;
pGeo.attributes.color.needsUpdate=true;
}
/* ---------- ambient motes ---------- */
const AMB=140;
const aPos=new Float32Array(AMB*3),aVel=new Float32Array(AMB*3);
for(let i=0;i<AMB;i++){
aPos[i*3]=rand(-BOUND,BOUND);aPos[i*3+1]=rand(0.2,7);aPos[i*3+2]=rand(-BOUND,BOUND);
aVel[i*3]=rand(-.15,.15);aVel[i*3+1]=rand(.05,.3);aVel[i*3+2]=rand(-.15,.15);
}
const aGeo=new THREE.BufferGeometry();
aGeo.setAttribute('position',new THREE.BufferAttribute(aPos,3).setUsage(THREE.DynamicDrawUsage));
const amb=new THREE.Points(aGeo,new THREE.PointsMaterial({size:0.12,map:glowTex,color:0x3ee6c4,transparent:true,opacity:0.35,depthWrite:false,blending:THREE.AdditiveBlending}));
amb.frustumCulled=false;scene.add(amb);
/* ---------- shockwave rings ---------- */
const shocks=[];
for(let i=0;i<4;i++){
const m=new THREE.Mesh(new THREE.RingGeometry(0.85,1.05,48),
new THREE.MeshBasicMaterial({color:0xffffff,transparent:true,opacity:0,side:THREE.DoubleSide,blending:THREE.AdditiveBlending,depthWrite:false}));
m.rotation.x=-Math.PI/2;m.position.y=0.06;m.visible=false;scene.add(m);
shocks.push({m,life:0});
}
function shock(x,z,hex){
const w=shocks.find(w=>w.life<=0)||shocks[0];
w.m.visible=true;w.m.position.set(x,0.06,z);w.m.material.color.setHex(hex);w.life=1;
}
/* ---------- floating labels ---------- */
const floaters=[];
const tmpV=new THREE.Vector3();
function popLabel(txt,x,y,z){
const el=document.createElement('div');
el.className='pop';el.textContent=txt;
$('#ui').appendChild(el);
floaters.push({el,pos:new THREE.Vector3(x,y,z),life:1});
}
function updateFloaters(dt){
for(let i=floaters.length-1;i>=0;i--){
const f=floaters[i];
f.life-=dt*0.9;f.pos.y+=dt*1.8;
if(f.life<=0){f.el.remove();floaters.splice(i,1);continue;}
tmpV.copy(f.pos).project(camera);
const x=(tmpV.x*0.5+0.5)*innerWidth,y=(-tmpV.y*0.5+0.5)*innerHeight;
f.el.style.transform=`translate(${x}px,${y}px) translate(-50%,-50%)`;
f.el.style.opacity=Math.min(1,f.life*1.6);
}
}
/* ---------- audio (WebAudio synth, no assets) ---------- */
let AC=null,master=null,muted=false;
function audioInit(){
if(AC)return;
try{
AC=new (window.AudioContext||window.webkitAudioContext)();
master=AC.createGain();master.gain.value=0.5;master.connect(AC.destination);
}catch(e){}
}
function tone(f0,f1,dur,type,vol,delay=0){
if(!AC||muted)return;
const t=AC.currentTime+delay;
const o=AC.createOscillator(),g=AC.createGain();
o.type=type;o.frequency.setValueAtTime(f0,t);
o.frequency.exponentialRampToValueAtTime(Math.max(1,f1),t+dur);
g.gain.setValueAtTime(vol,t);
g.gain.exponentialRampToValueAtTime(0.0001,t+dur);
o.connect(g).connect(master);o.start(t);o.stop(t+dur+0.05);
}
function noise(dur,vol,freq){
if(!AC||muted)return;
const t=AC.currentTime;
const n=AC.createBufferSource();
const len=Math.floor(AC.sampleRate*dur);
const buf=AC.createBuffer(1,len,AC.sampleRate);
const d=buf.getChannelData(0);
for(let i=0;i<len;i++)d[i]=Math.random()*2-1;
n.buffer=buf;
const f=AC.createBiquadFilter();f.type='lowpass';f.frequency.value=freq;
const g=AC.createGain();g.gain.setValueAtTime(vol,t);
g.gain.exponentialRampToValueAtTime(0.0001,t+dur);
n.connect(f).connect(g).connect(master);n.start(t);
}
const sfx={
collect(){tone(660,990,0.12,'sine',0.25);tone(1320,1760,0.18,'sine',0.12,0.06);},
hit(){tone(110,55,0.3,'square',0.3);noise(0.3,0.35,500);},
dash(){tone(300,700,0.15,'sawtooth',0.12);noise(0.15,0.1,1400);},
spawn(){noise(0.25,0.08,300);},
threat(){tone(220,330,0.15,'square',0.14);tone(330,440,0.2,'square',0.12,0.12);},
over(){tone(440,220,0.3,'sawtooth',0.2);tone(220,110,0.4,'sawtooth',0.2,0.25);tone(110,55,0.7,'sawtooth',0.22,0.55);},
ui(){tone(880,880,0.06,'sine',0.1);},
heart(){tone(55,40,0.12,'sine',0.25);tone(50,38,0.14,'sine',0.2,0.18);}
};
/* ---------- game state ---------- */
let state='menu',paused=false;
let score=0,dispScore=0,lives=3,elapsed=0,orbsCollected=0,maxThreat=1,threat=1;
let spawnT=1.4,dashCd=0,fovKick=0,camShake=0,dmgFlash=0;
let timeScale=1,hbT=0,menuT=0,overT=0,overA0=0,overReal=0,overShown=false;
let camYaw=0;
const camPos=new THREE.Vector3(0,9,-17);
const lookTarget=new THREE.Vector3();
let best=0;try{best=+localStorage.getItem('emberArenaBest')||0;}catch(e){}
const spawnInterval=()=>Math.max(0.55,2.4-elapsed*0.03);
const enemySpeed=()=>Math.min(9.2,3.1+elapsed*0.06);
/* ---------- HUD ---------- */
const heartEls=[...document.querySelectorAll('.heart')];
const segBox=$('#segs'),segs=[];
for(let i=0;i<8;i++){const s=document.createElement('i');s.className='seg';segBox.appendChild(s);segs.push(s);}
function setHearts(){
heartEls.forEach((h,i)=>{
if(i>=lives&&!h.classList.contains('lost'))h.classList.add('lost');
if(i<lives)h.classList.remove('lost');
});
}
function setThreatHUD(){
$('#threatVal').textContent='LV '+threat;
const on=Math.min(threat,segs.length);
segs.forEach((s,i)=>{s.classList.toggle('on',i<on);s.classList.toggle('hot',threat>=6&&i<on);});
}
function banner(txt){
const b=$('#banner');
b.textContent=txt;
b.classList.remove('show');void b.offsetWidth;b.classList.add('show');
}
let lastScoreTxt='',lastTimeTxt='',lastDash=-1;
function updateHUD(dt){
dispScore=lerp(dispScore,score,1-Math.exp(-10*dt));
const st=String(Math.round(dispScore)).padStart(5,'0');
if(st!==lastScoreTxt){$('#scoreVal').textContent=st;lastScoreTxt=st;}
const tt=fmt(elapsed);
if(tt!==lastTimeTxt){$('#timeVal').textContent=tt;lastTimeTxt=tt;}
const dw=Math.round((1-dashCd/DASH_CD)*1000)/10;
if(Math.abs(dw-lastDash)>0.5){$('#dashFill').style.width=dw+'%';lastDash=dw;}
dmgFlash=Math.max(0,dmgFlash-dt*1.7);
$('#dmg').style.opacity=dmgFlash.toFixed(3);
}
/* ---------- ambient visuals ---------- */
function updateOrb(dt){
orbFloat.position.y=1.25+Math.sin(tGlobal*2.4)*0.18;
orbCore.scale.setScalar(1+0.1*Math.sin(tGlobal*3.2));
orbSpin.rotation.y+=dt*1.9;
orbLight.intensity=1.5+0.5*Math.sin(tGlobal*3.2)+orbFlash*3;
orbFlash=Math.max(0,orbFlash-dt*2.5);
orbGlow.material.opacity=0.16+0.07*Math.sin(tGlobal*3.2);
}
function updateAmbient(dt){
ringGrpA.rotation.y+=dt*0.12;ringGrpB.rotation.y-=dt*0.08;
ringGrp.rotation.y+=dt*1.4;
stripMat.emissiveIntensity=0.9+0.35*Math.sin(tGlobal*1.8);
crystals.forEach(({cr,sp,i})=>{
cr.rotation.y+=dt*0.8;
cr.position.y=4.9+Math.sin(tGlobal*1.3+i)*0.15;
sp.position.y=cr.position.y;
});
updateOrb(dt);
for(let i=0;i<AMB;i++){
aPos[i*3]+=aVel[i*3]*dt;aPos[i*3+1]+=aVel[i*3+1]*dt;aPos[i*3+2]+=aVel[i*3+2]*dt;
if(aPos[i*3+1]>7.2){aPos[i*3+1]=0.3;aPos[i*3]=rand(-BOUND,BOUND);aPos[i*3+2]=rand(-BOUND,BOUND);}
}
aGeo.attributes.position.needsUpdate=true;
for(const w of shocks){
if(w.life<=0)continue;
w.life-=dt*1.4;
if(w.life<=0){w.m.visible=false;continue;}
const s=1+(1-w.life)*10;
w.m.scale.set(s,s,s);
w.m.material.opacity=w.life*0.65;
}
if(state==='menu'){player.position.y=0.55+Math.sin(tGlobal*3.1)*0.05;}
}
/* ---------- gameplay actions ---------- */
function collectOrb(){
score+=10;orbsCollected++;
const x=orbGrp.position.x,z=orbGrp.position.z;
burst(x,1.35,z,30,C_GOLD,6.5,0.7,2.4,3,2.5);
shock(x,z,C_GOLD);
popLabel('+10',x,2.3,z);
orbFlash=1;fovKick=Math.max(fovKick,4);
sfx.collect();
const sp=$('#scorePanel');sp.classList.remove('bump');void sp.offsetWidth;sp.classList.add('bump');
spawnOrb();
}
function hitPlayer(nx,nz){
lives--;setHearts();
P.invuln=1.6;
P.vel.x+=nx*10;P.vel.z+=nz*10;
camShake=0.65;dmgFlash=1;timeScale=0.3;
burst(P.pos.x,P.pos.y,P.pos.z,28,C_RED,7,0.6,2.2,3,1.5);
shock(P.pos.x,P.pos.z,C_RED);
sfx.hit();
const lp=$('#livesPanel');lp.classList.remove('shake');void lp.offsetWidth;lp.classList.add('shake');
if(lives<=0)gameOver();
else if(lives===1)$('#lowlife').classList.add('on');
}
function gameOver(){
state='over';overT=0;overReal=0;overShown=false;overA0=camYaw;
for(let i=enemies.length-1;i>=0;i--){
const e=enemies[i];
burst(e.g.position.x,e.g.position.y,e.g.position.z,14,C_RED,6,0.8,2,2,1);
scene.remove(e.g);
}
enemies.length=0;
pending.forEach(s=>s.m.visible=false);pending.length=0;
burst(P.pos.x,P.pos.y,P.pos.z,70,C_CYAN,8,1.1,1.8,2,2);
burst(P.pos.x,P.pos.y,P.pos.z,24,C_GOLD,5,0.9,2,1,1);
player.visible=false;
timeScale=0.22;camShake=0.8;dmgFlash=1;
sfx.over();
const nb=score>best;
if(nb){best=score;try{localStorage.setItem('emberArenaBest',String(best));}catch(e){}}
$('#finalScore').textContent=String(score).padStart(5,'0');
$('#stTime').textContent=fmt(elapsed);
$('#stOrbs').textContent=String(orbsCollected);
$('#stThreat').textContent='LV '+maxThreat;
$('#newBest').style.display=nb?'flex':'none';
$('#bestVal').textContent='BEST '+best;
if(best>0)$('#menuBest').textContent='PERSONAL BEST · '+best;
$('#lowlife').classList.remove('on');
}
function reset(){
score=0;dispScore=0;lastScoreTxt='';lives=3;elapsed=0;orbsCollected=0;maxThreat=1;threat=1;
spawnT=1.4;dashCd=0;camShake=0;fovKick=0;dmgFlash=0;timeScale=1;hbT=0;
P.pos.set(0,0.55,0);P.vel.set(0,0,0);P.heading=0;P.invuln=1.2;
enemies.forEach(e=>scene.remove(e.g));enemies.length=0;
pending.forEach(s=>s.m.visible=false);pending.length=0;
floaters.forEach(f=>f.el.remove());floaters.length=0;
player.visible=true;
setHearts();setThreatHUD();
$('#lowlife').classList.remove('on');
$('#menuScreen').classList.remove('on');
$('#overScreen').classList.remove('on');
$('#pauseScreen').classList.remove('on');
camYaw=Math.atan2(camera.position.x-P.pos.x,camera.position.z-P.pos.z);
state='play';
sfx.ui();
$('#hint').classList.remove('fade');
clearTimeout(hintTO);
hintTO=setTimeout(()=>$('#hint').classList.add('fade'),7000);
}
function togglePause(){
if(state!=='play')return;
paused=!paused;
$('#pauseScreen').classList.toggle('on',paused);
sfx.ui();
}
/* ---------- simulation ---------- */
function sim(dt){
elapsed+=dt;
// difficulty ramp + threat milestones
const lvl=1+Math.floor(elapsed/16);
if(lvl>threat){
threat=lvl;maxThreat=Math.max(maxThreat,threat);
banner('THREAT LEVEL '+threat);sfx.threat();setThreatHUD();
scheduleSpawn();scheduleSpawn();
}
spawnT-=dt;
if(spawnT<=0){scheduleSpawn();spawnT=spawnInterval()*rand(0.75,1.3);}
for(let i=pending.length-1;i>=0;i--){
const s=pending[i];s.t-=dt;
const k=Math.min(1,1-s.t/0.55);
s.m.scale.setScalar(0.5+k*1.4);
s.m.material.opacity=0.2+0.6*(s.t/0.55);
if(s.t<=0){s.m.visible=false;spawnEnemy(s.x,s.z);pending.splice(i,1);}
}
// player — camera-relative input with momentum
let iz=(keys['w']||keys['arrowup']?1:0)-(keys['s']||keys['arrowdown']?1:0);
let ix=(keys['d']||keys['arrowright']?1:0)-(keys['a']||keys['arrowleft']?1:0);
const il=Math.hypot(ix,iz);if(il>1){ix/=il;iz/=il;}
const fx=Math.sin(camYaw),fz=Math.cos(camYaw);
const wx=fx*iz-fz*ix,wz=fz*iz+fx*ix;
P.vel.x+=wx*ACC*dt;P.vel.z+=wz*ACC*dt;
P.vel.multiplyScalar(Math.exp(-2.7*dt));
if(P.vel.length()>MAXS)P.vel.multiplyScalar(Math.exp(-2.0*dt));
P.pos.x+=P.vel.x*dt;P.pos.z+=P.vel.z*dt;
if(P.pos.x>BOUND){P.pos.x=BOUND;if(P.vel.x>0)P.vel.x*=-0.3;}
if(P.pos.x<-BOUND){P.pos.x=-BOUND;if(P.vel.x<0)P.vel.x*=-0.3;}
if(P.pos.z>BOUND){P.pos.z=BOUND;if(P.vel.z>0)P.vel.z*=-0.3;}
if(P.pos.z<-BOUND){P.pos.z=-BOUND;if(P.vel.z<0)P.vel.z*=-0.3;}
const spd0=P.vel.length();
if(spd0>0.9)P.heading=lerpAngle(P.heading,Math.atan2(P.vel.x,P.vel.z),1-Math.exp(-7*dt));
dashCd=Math.max(0,dashCd-dt);
if(keys['shift']&&dashCd<=0&&(ix||iz)){
dashCd=DASH_CD;
P.vel.x+=wx*12;P.vel.z+=wz*12;
fovKick=9;camShake=Math.max(camShake,0.1);
burst(P.pos.x,P.pos.y,P.pos.z,12,C_CYAN,4,0.4,3,0,0.5);
sfx.dash();
}
P.invuln=Math.max(0,P.invuln-dt);
// player visuals
player.position.set(P.pos.x,0.55+Math.sin(tGlobal*3.1)*0.05,P.pos.z);
player.rotation.y=P.heading;
const sh=Math.sin(P.heading),ch=Math.cos(P.heading);
const fwd=P.vel.x*sh+P.vel.z*ch;
const lat=-P.vel.x*ch+P.vel.z*sh;
tilt.rotation.x=lerp(tilt.rotation.x,clamp(fwd*0.022,-0.3,0.45),1-Math.exp(-9*dt));
tilt.rotation.z=lerp(tilt.rotation.z,clamp(-lat*0.035,-0.5,0.5),1-Math.exp(-9*dt));
const flick=P.invuln>0&&Math.sin(tGlobal*32)>0;
hullMat.opacity=flick?0.3:1;hullMat.transparent=P.invuln>0;
canopyMat.opacity=flick?0.35:1;canopyMat.transparent=P.invuln>0;
const sp01=Math.min(1,spd0/10);
engine.material.opacity=0.45+sp01*0.55;
const es=1.1+sp01*1.3;engine.scale.set(es,es,1);
pLight.intensity=0.5+sp01*0.9;
emitTrail(dt);
// enemies — chase, wobble, collide
for(let i=enemies.length-1;i>=0;i--){
const e=enemies[i];
e.spawnT=Math.min(1,e.spawnT+dt*2.1);
const dx=P.pos.x-e.g.position.x,dz=P.pos.z-e.g.position.z;
const d=Math.hypot(dx,dz)||0.001;
const nx=dx/d,nz=dz/d;
const sp=enemySpeed()*e.spdMul;
const wob=Math.sin(tGlobal*2.3+e.phase)*0.45;
e.g.position.x+=(nx*sp-nz*wob)*dt;
e.g.position.z+=(nz*sp+nx*wob)*dt;
e.g.position.y=1.05+Math.sin(tGlobal*2.6+e.phase)*0.16;
const u=e.spawnT,c1=1.70158,c3=c1+1;
e.g.scale.setScalar(Math.max(0.001,1+c3*Math.pow(u-1,3)+c1*Math.pow(u-1,2)));
e.core.rotation.x+=dt*1.5;e.core.rotation.y+=dt*0.8;
e.cage.rotation.y-=dt*1.2;e.cage.rotation.x+=dt*0.6;
if(e.spawnT>0.5&&d<1.45&&P.invuln<=0){
hitPlayer(nx,nz);
removeEnemy(i);
if(state!=='play')break;
}
}
// orb — magnet pull + collect
{
const dx=P.pos.x-orbGrp.position.x,dz=P.pos.z-orbGrp.position.z;
const d=Math.hypot(dx,dz);
if(d<3.4&&d>0.01){orbGrp.position.x+=dx/d*3.2*dt;orbGrp.position.z+=dz/d*3.2*dt;}
if(d<1.5)collectOrb();
}
// last-life heartbeat
if(lives===1){hbT-=dt;if(hbT<=0){sfx.heart();hbT=1.15;}}
}
/* ---------- camera ---------- */
function updateCamera(dt){
if(state==='menu'){
menuT+=dt;
const a=menuT*0.1;
camPos.lerp(tmpV.set(Math.sin(a)*17,8.5+Math.sin(menuT*0.23),Math.cos(a)*17),1-Math.exp(-1.2*dt));
camera.position.copy(camPos);
camera.lookAt(0,1.2,0);
camera.fov=lerp(camera.fov,55,1-Math.exp(-2*dt));
camera.updateProjectionMatrix();
return;
}
if(state==='over'){
overT+=dt;
const a=overA0+overT*0.22,r=Math.max(9,13-overT*0.4);
camPos.lerp(tmpV.set(Math.sin(a)*r,8+overT*0.5,Math.cos(a)*r),1-Math.exp(-1.5*dt));
camera.position.copy(camPos);
camera.lookAt(0,0.6,0);
camera.fov=lerp(camera.fov,52,1-Math.exp(-2*dt));
camera.updateProjectionMatrix();
return;
}
// playing
if(!paused){
camYaw=lerpAngle(camYaw,P.heading,1-Math.exp(-4.2*dt));
const spd=P.vel.length();
const dist=6.6+Math.min(spd,12)*0.14,h=3.5+Math.min(spd,12)*0.05;
camPos.lerp(tmpV.set(P.pos.x-Math.sin(camYaw)*dist,h,P.pos.z-Math.cos(camYaw)*dist),1-Math.exp(-5*dt));
lookTarget.set(P.pos.x+P.vel.x*0.22,P.pos.y+0.75,P.pos.z+P.vel.z*0.22);
fovKick=Math.max(0,fovKick-dt*22);
camera.fov=lerp(camera.fov,58+Math.min(spd,13)*0.5+fovKick,1-Math.exp(-6*dt));
}
camera.position.copy(camPos);
camera.lookAt(lookTarget);
camera.updateProjectionMatrix();
if(camShake>0.001){
camShake*=Math.exp(-5*dt);
camera.position.x+=(Math.random()-0.5)*camShake;
camera.position.y+=(Math.random()-0.5)*camShake*0.7;
camera.position.z+=(Math.random()-0.5)*camShake;
}
}
/* ---------- input ---------- */
const keys={};
let hintTO=null;
addEventListener('keydown',e=>{
const k=e.key.toLowerCase();
if(k.startsWith('arrow')||k===' ')e.preventDefault();
audioInit();
if(AC&&AC.state==='suspended')AC.resume();
if(!e.repeat){
if(state==='menu'){keys[k]=true;reset();return;}
if(state==='over'){keys[k]=true;if(overReal>1.0)reset();return;}
if(k==='m'){muted=!muted;if(master)master.gain.value=muted?0:0.5;$('#muteTag').textContent=muted?'MUTED · [M]':'[M] MUTE';return;}
if(k==='p'||k==='escape'){togglePause();return;}
}
keys[k]=true;
});
addEventListener('keyup',e=>{keys[e.key.toLowerCase()]=false;});
addEventListener('blur',()=>{for(const k in keys)keys[k]=false;});
addEventListener('pointerdown',()=>{
audioInit();
if(AC&&AC.state==='suspended')AC.resume();
if(state==='menu')reset();
else if(state==='over'&&overReal>1.2)reset();
});
/* ---------- main loop ---------- */
let tGlobal=0,last=performance.now();
function loop(now){
requestAnimationFrame(loop);
const dt=Math.min((now-last)/1000,0.05);last=now;
timeScale=lerp(timeScale,1,1-Math.exp(-3.2*dt));
const sdt=dt*timeScale;
tGlobal+=sdt;
if(state==='over')overReal+=dt;
updateAmbient(sdt);
updateParticles(sdt);
if(state==='play'&&!paused){
sim(sdt);
updateCamera(sdt);
}else if(state==='menu'){
updateCamera(sdt);
}else if(state==='over'){
updateCamera(sdt);
if(!overShown&&overReal>0.9){overShown=true;$('#overScreen').classList.add('on');}
}
updateHUD(dt);
if(!(state==='play'&&paused))updateFloaters(dt);
renderer.render(scene,camera);
}
/* ---------- init ---------- */
addEventListener('resize',()=>{
camera.aspect=innerWidth/innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth,innerHeight);
});
spawnOrb();
setHearts();
setThreatHUD();
$('#bestVal').textContent='BEST '+best;
if(best>0)$('#menuBest').textContent='PERSONAL BEST · '+best;
camera.position.copy(camPos);
requestAnimationFrame(loop);
</script>
</body>
</html>

Build a 3D arena game as a SINGLE self-contained .html file.

STACK (mandatory):

  • Three.js loaded from a CDN (one <script> tag). No other JS libraries, no build step.
  • All HTML, CSS, and JS in this one file. It must run by opening it directly in a browser.

CORE SPEC (mandatory — implement all of this exactly):

  1. A flat ground plane forming a bounded arena. The player cannot leave its bounds.
  2. A player object on the ground. WASD moves it (camera-relative); movement has momentum, not instant stop/start.
  3. A third-person camera that smoothly follows behind the player.
  4. Collectible glowing orbs spawn at random positions. Touching one collects it (+10 score) and spawns a new one.
  5. Enemy objects spawn at the arena edges and move toward the player. Contact with the player costs 1 life.
  6. Player starts with 3 lives. A HUD shows score and lives at all times.
  7. At 0 lives: a game-over screen showing final score, with a key press to restart.
  8. Difficulty ramps over time (enemies spawn faster and/or move faster).

STRETCH (strongly encouraged — you will be judged on this): Beyond the core, make it feel PREMIUM. Lighting, shadows, particles, juice, smooth camera, satisfying feedback, polished HUD, atmosphere. Add depth or complexity if it improves the experience. Aim to genuinely impress — this is evaluated on visual quality and feel, not just correctness.

RULES:

  • Implement the full core before adding stretch features.
  • Output the complete, ready-to-run .html file.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment