DOOM
GLM 5.3 Flash · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 33,733 bytes · SHA-256 f3d71834b684
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>GRIM KEEP — Retro FPS</title>
<style>
html,body{margin:0;padding:0;background:#000;height:100%;overflow:hidden;font-family:'Courier New',monospace;}
#wrap{position:relative;width:100vw;height:100vh;display:flex;align-items:center;justify-content:center;}
canvas{image-rendering:pixelated;cursor:crosshair;}
#msg{position:absolute;top:12%;left:50%;transform:translateX(-50%);color:#f44;font-size:20px;font-weight:bold;
text-shadow:2px 2px #000;pointer-events:none;opacity:0;transition:opacity .3s;white-space:nowrap;}
#overlay{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;
background:rgba(0,0,0,.82);color:#ccc;text-align:center;z-index:10;}
#overlay h1{color:#e33;font-size:52px;letter-spacing:6px;margin:0 0 10px;text-shadow:3px 3px #500;}
#overlay h2{color:#ee5;font-size:30px;margin:0 0 18px;}
#overlay p{max-width:560px;line-height:1.7;font-size:15px;}
#overlay .k{color:#8f8;}
#overlay button{margin-top:22px;padding:12px 34px;font-size:20px;font-family:inherit;background:#611;color:#fcc;
border:2px solid #f66;cursor:pointer;letter-spacing:2px;}
#overlay button:hover{background:#922;}
.hidden{display:none!important;}
</style>
</head>
<body>
<div id="wrap">
<canvas id="cv" width="960" height="600"></canvas>
<div id="msg"></div>
<div id="overlay">
<h1>GRIM KEEP</h1>
<h2 id="ovsub">A demon-blasting dungeon crawl</h2>
<p id="ovtext">
Purge every demon from the keep, then step on the glowing <span class="k">EXIT</span> pad.<br><br>
<span class="k">WASD</span> move · <span class="k">MOUSE</span> aim · <span class="k">CLICK</span> fire<br>
<span class="k">1/2/3</span> weapons · <span class="k">SHIFT</span> sprint · <span class="k">ESC</span> release mouse<br><br>
Grab <span class="k">medkits</span> and <span class="k">ammo</span> — you'll need them.
</p>
<button id="startbtn">ENTER THE KEEP</button>
</div>
</div>
<script>
"use strict";
// ============================== SETUP ==============================
const cv = document.getElementById('cv'), ctx = cv.getContext('2d');
const W = cv.width, H = cv.height;
const msgEl = document.getElementById('msg');
const overlay = document.getElementById('overlay');
const ovsub = document.getElementById('ovsub'), ovtext = document.getElementById('ovtext');
function fitCanvas(){
const s = Math.min(innerWidth / W, innerHeight / H);
cv.style.width = (W*s)+'px'; cv.style.height = (H*s)+'px';
}
addEventListener('resize', fitCanvas); fitCanvas();
function showMsg(t, col='#f44'){
msgEl.textContent = t; msgEl.style.color = col; msgEl.style.opacity = 1;
clearTimeout(showMsg.t); showMsg.t = setTimeout(()=>msgEl.style.opacity=0, 1600);
}
// ============================== MAP ==============================
// 1 brick, 2 stone, 3 tech, 0 floor. 'E' exit pad handled separately.
const MAPSTR = [
"111111111111111111111111",
"100000000100000000000001",
"101111110101111011111101",
"101000010100010000010001",
"101011010111010111010111",
"100001010000010001000001",
"111101011111010101011101",
"100001000001010101000001",
"101111111101010101011101",
"101000000101010000010001",
"101011111101011111010111",
"101010000000010001010001",
"101010111111010111011101",
"100010000001000000000001",
"111010111101011111111111",
"100010100001010000000001",
"101110101111010111110101",
"101000100000010100010101",
"101011111111010101010101",
"100010000000010101000101",
"111110111111010101110101",
"100000000001010000000101",
"101111111101011111110101",
"111111111111111111111111"];
const MW = 24, MH = 24;
const map = [];
for(let y=0;y<MH;y++){ map.push([]); for(let x=0;x<MW;x++) map[y].push(MAPSTR[y].charCodeAt(x)-48); }
const tile = (x,y)=> (x<0||y<0||x>=MW||y>=MH)?1:map[y|0][x|0];
const solid = (x,y)=> tile(x,y)>0;
const START = {x:2.5, y:1.5};
const EXIT = {x:22.5, y:22.5};
// ============================== TEXTURES ==============================
function makeTex(fn){
const c = document.createElement('canvas'); c.width = c.height = 64;
const g = c.getContext('2d'); fn(g); return c;
}
function noise(g, base, amt, alpha){
for(let i=0;i<64;i++)for(let j=0;j<64;j++){
const n = (Math.random()-0.5)*amt;
g.fillStyle = `rgba(${n>0?255:0},${n>0?255:0},${n>0?255:0},${Math.abs(n)*alpha})`;
g.fillRect(i,j,1,1);
}
}
const TEX = [];
TEX[1] = makeTex(g=>{ // brick
g.fillStyle='#6b2a22'; g.fillRect(0,0,64,64);
for(let r=0;r<8;r++)for(let cB=0;cB<4;cB++){
const off = (r%2)*8;
g.fillStyle = ['#7d332a','#6e2c24','#83382c','#743028'][(r+cB)%4];
g.fillRect(cB*16+off-8, r*8, 15, 7);
}
noise(g,0,0.5,0.25);
});
TEX[2] = makeTex(g=>{ // stone
g.fillStyle='#5a5a62'; g.fillRect(0,0,64,64);
for(let r=0;r<4;r++)for(let cB=0;cB<2;cB++){
g.fillStyle = ['#66666e','#585862','#6d6d74','#5e5e66'][(r+cB)%4];
g.fillRect(cB*32+(r%2)*16-16, r*16, 31, 15);
}
g.strokeStyle='#3c3c44'; for(let r=0;r<=4;r++){g.beginPath();g.moveTo(0,r*16);g.lineTo(64,r*16);g.stroke();}
noise(g,0,0.6,0.3);
});
TEX[3] = makeTex(g=>{ // tech
g.fillStyle='#3a4148'; g.fillRect(0,0,64,64);
g.fillStyle='#2c3238'; g.fillRect(0,0,64,8); g.fillRect(0,56,64,8);
g.fillStyle='#4a545c'; g.fillRect(4,12,24,40);
g.fillStyle='#57626c'; g.fillRect(36,12,24,40);
for(let i=0;i<5;i++){ g.fillStyle = i%2?'#7fe37f':'#2f5a2f'; g.fillRect(8+i*3,16,2,6); }
g.fillStyle='#c8842c'; g.fillRect(40,20,16,4); g.fillRect(40,30,16,4); g.fillRect(40,40,16,4);
noise(g,0,0.35,0.2);
});
TEX[4] = makeTex(g=>{ // exit-adjacent glowing wall
g.fillStyle='#22262c'; g.fillRect(0,0,64,64);
g.fillStyle='#3c88c8'; g.fillRect(8,8,48,48);
g.fillStyle='#9fd8ff'; g.font='bold 34px monospace'; g.textAlign='center'; g.fillText('E',32,45);
noise(g,0,0.3,0.15);
});
// ============================== SPRITES ==============================
function makeSprite(fn, w=64, h=64){
const c = document.createElement('canvas'); c.width=w; c.height=h;
const g = c.getContext('2d'); fn(g,w,h); return c;
}
function demonBody(g, skin, eye, mouthOpen){
g.fillStyle=skin; // legs
g.fillRect(20,44,9,18); g.fillRect(35,44,9,18);
g.fillStyle='rgba(0,0,0,.3)'; g.fillRect(20,58,9,4); g.fillRect(35,58,9,4);
g.fillStyle=skin; g.fillRect(14,18,36,28); // torso
// arms
g.fillRect(6,20,9,22); g.fillRect(49,20,9,22);
g.fillStyle=skin; g.beginPath(); g.arc(32,14,13,0,7); g.fill(); // head
// horns
g.fillStyle='#d8cfa8';
g.beginPath(); g.moveTo(20,6); g.lineTo(15,-2+8); g.lineTo(24,4); g.fill();
g.beginPath(); g.moveTo(44,6); g.lineTo(49,6); g.lineTo(40,4); g.fill();
// eyes
g.fillStyle=eye; g.fillRect(24,10,6,5); g.fillRect(34,10,6,5);
// mouth
g.fillStyle='#fff';
if(mouthOpen){ g.fillRect(24,20,16,7); g.fillStyle='#701010'; g.fillRect(25,21,14,5); }
else { for(let i=0;i<4;i++) g.fillRect(24+i*4,20,3,3); }
}
const SPR = {};
SPR.imp = [0,1].map(f=>makeSprite((g)=>{
demonBody(g, f? '#8a4a2a':'#94522c', '#ffd23c', f);
}));
SPR.impAtk = makeSprite(g=>{ demonBody(g,'#a85a30','#ff5030',true); g.fillStyle='#ff8030'; g.beginPath(); g.arc(32,32,10,0,7); g.fill(); });
SPR.impDie = [0,1,2].map(f=>makeSprite(g=>{
g.save(); g.translate(32,64); g.rotate(f*0.5); g.translate(-32,-64+f*14);
demonBody(g, ['#7a3a20','#5a2a16','#3a1a0e'][f], '#331', false); g.restore();
}));
SPR.brute = [0,1].map(f=>makeSprite(g=>{
g.fillStyle='#3f5a3a'; g.fillRect(16,40,12,22); g.fillRect(36,40,12,22);
g.fillStyle='#4a6a44'; g.fillRect(10,14,44,30);
g.fillStyle='#4a6a44'; g.fillRect(2,16+f*2,10,26); g.fillRect(52,16+f*2,10,26);
g.beginPath(); g.arc(32,12,14,0,7); g.fill();
g.fillStyle='#e04030'; g.fillRect(22,8,8,5); g.fillRect(34,8,8,5);
g.fillStyle='#c8c8b0'; g.fillRect(24,17,16,4);
g.fillStyle='#8f8f78'; g.fillRect(8,20,6,4); g.fillRect(50,20,6,4);
}));
SPR.bruteAtk = makeSprite(g=>{
g.fillStyle='#4a6a44'; g.fillRect(10,14,44,30); g.fillRect(16,40,12,22); g.fillRect(36,40,12,22);
g.fillRect(2,10,10,26); g.fillRect(52,10,10,26);
g.beginPath(); g.arc(32,12,14,0,7); g.fill();
g.fillStyle='#ff5040'; g.fillRect(22,8,8,5); g.fillRect(34,8,8,5);
g.fillStyle='#70ff70'; g.beginPath(); g.arc(32,34,9,0,7); g.fill();
});
SPR.bruteDie = [0,1,2].map(f=>makeSprite(g=>{
g.save(); g.translate(32,64); g.rotate(f*0.55); g.translate(-32,-64+f*16);
g.fillStyle=['#3a4a36','#2c382a','#20281e'][f]; g.fillRect(10,14,44,44);
g.beginPath(); g.arc(32,12,14,0,7); g.fill(); g.restore();
}));
SPR.fireball = makeSprite(g=>{
const gr = g.createRadialGradient(32,32,2,32,32,16);
gr.addColorStop(0,'#fff2a0'); gr.addColorStop(.5,'#ff8020'); gr.addColorStop(1,'rgba(200,40,0,0)');
g.fillStyle=gr; g.beginPath(); g.arc(32,32,16,0,7); g.fill();
});
SPR.medkit = makeSprite(g=>{
g.fillStyle='#d8d8d8'; g.fillRect(12,22,40,30);
g.fillStyle='#b03030'; g.fillRect(26,28,12,18); g.fillRect(16,32,32,10);
g.fillStyle='#eee'; g.fillRect(12,22,40,4);
});
SPR.ammo = makeSprite(g=>{
g.fillStyle='#6a5a2a'; g.fillRect(14,30,36,22);
g.fillStyle='#8a7a3a'; g.fillRect(14,30,36,5);
g.fillStyle='#c8b040'; for(let i=0;i<4;i++) g.fillRect(18+i*8,20,5,12);
g.fillStyle='#e8d860'; for(let i=0;i<4;i++) g.fillRect(18+i*8,20,5,3);
});
SPR.shells = makeSprite(g=>{
g.fillStyle='#7a2020'; for(let i=0;i<4;i++){ g.fillRect(16+i*9,26,7,24); g.fillStyle='#c8a030'; g.fillRect(16+i*9,44,7,6); g.fillStyle='#7a2020'; }
g.fillStyle='#a83838'; for(let i=0;i<4;i++) g.fillRect(16+i*9,26,7,4);
});
SPR.chaingun = makeSprite(g=>{
g.fillStyle='#33383c'; g.fillRect(10,32,44,14);
g.fillStyle='#4c545a'; g.fillRect(10,32,44,5);
g.fillStyle='#22262a'; for(let i=0;i<4;i++) g.fillRect(46+i*4,30,3,18);
g.fillStyle='#555c62'; g.fillRect(20,44,10,12);
});
SPR.barrel = makeSprite(g=>{
g.fillStyle='#3a5a8a'; g.fillRect(18,14,28,48);
g.fillStyle='#4a6ea8'; g.fillRect(18,14,28,6); g.fillRect(18,14,8,48);
g.fillStyle='#2a4060'; g.fillRect(18,24,28,3); g.fillRect(18,44,28,3);
g.fillStyle='#c8a020'; g.fillRect(26,30,12,10);
});
// ============================== AUDIO ==============================
let AC = null;
function sfx(type){
try{
if(!AC) AC = new (window.AudioContext||window.webkitAudioContext)();
const t = AC.currentTime, o = AC.createOscillator(), g = AC.createGain();
o.connect(g); g.connect(AC.destination);
if(type==='pistol'){ o.type='square'; o.frequency.setValueAtTime(700,t); o.frequency.exponentialRampToValueAtTime(120,t+.1); g.gain.setValueAtTime(.12,t); g.gain.exponentialRampToValueAtTime(.001,t+.12); o.start(t); o.stop(t+.13);}
else if(type==='shotgun'){ o.type='sawtooth'; o.frequency.setValueAtTime(300,t); o.frequency.exponentialRampToValueAtTime(60,t+.22); g.gain.setValueAtTime(.22,t); g.gain.exponentialRampToValueAtTime(.001,t+.25); o.start(t); o.stop(t+.26);}
else if(type==='chain'){ o.type='square'; o.frequency.setValueAtTime(500+Math.random()*200,t); o.frequency.exponentialRampToValueAtTime(100,t+.06); g.gain.setValueAtTime(.09,t); g.gain.exponentialRampToValueAtTime(.001,t+.07); o.start(t); o.stop(t+.08);}
else if(type==='hurt'){ o.type='sawtooth'; o.frequency.setValueAtTime(180,t); o.frequency.exponentialRampToValueAtTime(70,t+.25); g.gain.setValueAtTime(.2,t); g.gain.exponentialRampToValueAtTime(.001,t+.28); o.start(t); o.stop(t+.3);}
else if(type==='pickup'){ o.type='sine'; o.frequency.setValueAtTime(500,t); o.frequency.setValueAtTime(800,t+.07); g.gain.setValueAtTime(.15,t); g.gain.exponentialRampToValueAtTime(.001,t+.2); o.start(t); o.stop(t+.22);}
else if(type==='edie'){ o.type='sawtooth'; o.frequency.setValueAtTime(150,t); o.frequency.exponentialRampToValueAtTime(30,t+.4); g.gain.setValueAtTime(.2,t); g.gain.exponentialRampToValueAtTime(.001,t+.45); o.start(t); o.stop(t+.5);}
else if(type==='ehit'){ o.type='triangle'; o.frequency.setValueAtTime(250,t); o.frequency.exponentialRampToValueAtTime(90,t+.1); g.gain.setValueAtTime(.12,t); g.gain.exponentialRampToValueAtTime(.001,t+.12); o.start(t); o.stop(t+.13);}
}catch(e){}
}
// ============================== STATE ==============================
const WEAPONS = [
{name:'PISTOL', ammoType:'bullets', use:1, dmg:16, rate:0.38, spread:0.012, pellets:1, sfx:'pistol', kick:4},
{name:'SHOTGUN', ammoType:'shells', use:1, dmg:11, rate:0.85, spread:0.09, pellets:6, sfx:'shotgun', kick:10},
{name:'CHAINGUN', ammoType:'bullets', use:1, dmg:10, rate:0.09, spread:0.045, pellets:1, sfx:'chain', kick:3},
];
const AMMO_MAX = {bullets:200, shells:50};
let P, enemies, pickups, projectiles, particles, gameState, kills, totalEnemies, timeSec;
let shakeT = 0, flashT = 0, muzzleT = 0, bobT = 0;
function resetGame(){
P = { x:START.x, y:START.y, a:0, hp:100, ammo:{bullets:48, shells:0},
weapon:0, owned:[true,false,false], cool:0, dead:false, respawnT:0, painT:0 };
kills = 0; timeSec = 0;
enemies = []; pickups = []; projectiles = []; particles = [];
const spawn = (list, cls) => list.forEach(([x,y])=>{
if(!solid(x,y)) enemies.push(makeEnemy(cls, x+.5, y+.5));
});
spawn([[6,4],[10,9],[17,4],[20,9],[13,13],[4,13],[19,17],[9,17],[20,21],[13,21],[3,19],[16,9]], 'imp');
spawn([[13,5],[5,9],[21,13],[9,13],[16,17],[3,9],[13,17],[20,4]], 'brute');
totalEnemies = enemies.length;
const drop = (list, kind) => list.forEach(([x,y])=>{ if(!solid(x,y)) pickups.push({kind, x:x+.5, y:y+.5, bob:Math.random()*6}); });
drop([[3,3],[8,6],[14,3],[19,6],[11,10],[6,12],[18,12],[2,15],[9,15],[15,15],[21,16],[5,18],[12,19],[18,19],[8,21],[14,21],[21,20]], 'medkit');
drop([[5,3],[12,4],[17,7],[3,7],[10,8],[19,10],[7,13],[14,13],[2,12],[20,15],[11,16],[4,17],[16,18],[9,19],[13,20],[6,20],[22,21]], 'ammo');
drop([[12,7],[8,10],[17,13],[5,15],[19,18],[11,22]], 'shells');
drop([[15,5],[3,11],[13,15],[7,18],[18,21]], 'weapon1');
drop([[10,12],[21,9],[15,20]], 'weapon2');
pickups.push({kind:'exit', x:EXIT.x, y:EXIT.y, bob:0});
}
function makeEnemy(cls, x, y){
const imp = cls==='imp';
return { cls, x, y, hp: imp?30:70, speed: imp?1.7:1.15, dmg: imp?9:16,
range: imp? 12 : 9, // sight range
melee: imp? 1.0 : 1.3, atkCool: 1+Math.random(), painT:0, atkT:0, dieT:0,
frame:0, ft:0, dead:false, state:'idle', wanderA: Math.random()*6.28, wanderT:0 };
}
// ============================== INPUT ==============================
const keys = {};
let mouseDown = false, locked = false;
addEventListener('keydown', e=>{
keys[e.code]=true;
if(e.code==='Digit1') switchWeapon(0);
if(e.code==='Digit2') switchWeapon(1);
if(e.code==='Digit3') switchWeapon(2);
if(['KeyW','KeyA','KeyS','KeyD','Space','ShiftLeft'].includes(e.code)) e.preventDefault();
});
addEventListener('keyup', e=>keys[e.code]=false);
cv.addEventListener('mousedown', e=>{ if(locked) mouseDown=true; });
addEventListener('mouseup', ()=>mouseDown=false);
cv.addEventListener('click', ()=>{ if(gameState==='play' && !locked) cv.requestPointerLock(); });
document.addEventListener('pointerlockchange', ()=>{ locked = document.pointerLockElement===cv; });
addEventListener('mousemove', e=>{
if(locked && gameState==='play' && !P.dead) P.a += e.movementX*0.0022;
});
function switchWeapon(i){
if(!P || !P.owned[i] || P.weapon===i) return;
P.weapon = i; P.cool = Math.max(P.cool, 0.25);
showMsg(WEAPONS[i].name, '#ee5');
}
document.getElementById('startbtn').addEventListener('click', ()=>{
overlay.classList.add('hidden');
resetGame(); gameState='play';
cv.requestPointerLock();
showMsg('KILL THEM ALL', '#ee5');
});
function endScreen(win){
gameState = win?'win':'dead-final';
document.exitPointerLock && document.exitPointerLock();
overlay.classList.remove('hidden');
ovsub.textContent = win? 'THE KEEP IS CLEANSED' : 'YOU DIED... FOR GOOD';
ovsub.style.color = win? '#8f8':'#f44';
ovtext.innerHTML = win
? `All <span class="k">${totalEnemies}</span> demons purged in <span class="k">${timeSec.toFixed(1)}s</span>.<br>The Grim Keep is silent once more.`
: `The demons feasted upon your bones.<br><span class="k">${kills}</span> of ${totalEnemies} demons slain.`;
document.getElementById('startbtn').textContent = 'PLAY AGAIN';
}
// ============================== HELPERS ==============================
function castRay(ox, oy, dx, dy){
// DDA, returns {dist, side, tex, wallX}
let mapX=ox|0, mapY=oy|0;
const ddx = Math.abs(1/(dx||1e-9)), ddy = Math.abs(1/(dy||1e-9));
let stepX, stepY, sdx, sdy;
if(dx<0){stepX=-1; sdx=(ox-mapX)*ddx;} else {stepX=1; sdx=(mapX+1-ox)*ddx;}
if(dy<0){stepY=-1; sdy=(oy-mapY)*ddy;} else {stepY=1; sdy=(mapY+1-oy)*ddy;}
let side=0;
for(let i=0;i<64;i++){
if(sdx<sdy){ sdx+=ddx; mapX+=stepX; side=0; } else { sdy+=ddy; mapY+=stepY; side=1; }
if(tile(mapX,mapY)>0) break;
}
const dist = side===0 ? sdx-ddx : sdy-ddy;
let wallX = side===0 ? oy+dist*dy : ox+dist*dx;
wallX -= Math.floor(wallX);
return {dist:Math.max(dist,0.0001), side, tex:tile(mapX,mapY), wallX};
}
function los(x1,y1,x2,y2){
const dx=x2-x1, dy=y2-y1, d=Math.hypot(dx,dy);
if(d<0.01) return true;
return castRay(x1,y1,dx/d,dy/d).dist >= d-0.05;
}
function moveCirc(e, mx, my, r){
if(!solid(e.x+mx+Math.sign(mx)*r, e.y-r) && !solid(e.x+mx+Math.sign(mx)*r, e.y+r)) e.x+=mx;
if(!solid(e.x-r, e.y+my+Math.sign(my)*r) && !solid(e.x+r, e.y+my+Math.sign(my)*r)) e.y+=my;
}
function spawnParticles(x,y,col,n,spd){
for(let i=0;i<n;i++){
const a=Math.random()*6.283, s=spd*(0.3+Math.random());
particles.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,life:0.4+Math.random()*0.4,col,size:2+Math.random()*3});
}
}
// ============================== COMBAT ==============================
function fire(){
const w = WEAPONS[P.weapon];
if(P.cool>0 || P.dead) return;
if(P.ammo[w.ammoType] < w.use){ showMsg('OUT OF AMMO','#f80'); P.cool=0.3; return; }
P.ammo[w.ammoType] -= w.use;
P.cool = w.rate; muzzleT = 0.07; shakeT = Math.max(shakeT, w.kick*0.012);
sfx(w.sfx);
for(let p=0;p<w.pellets;p++){
const ang = P.a + (Math.random()-0.5)*2*w.spread;
hitscan(ang, w.dmg);
}
}
function hitscan(ang, dmg){
const dx=Math.cos(ang), dy=Math.sin(ang);
const wallD = castRay(P.x,P.y,dx,dy).dist;
let best=null, bestD=wallD;
for(const e of enemies){
if(e.dead) continue;
const rx=e.x-P.x, ry=e.y-P.y;
const along = rx*dx+ry*dy;
if(along<=0.2 || along>=bestD) continue;
const perp = Math.abs(rx*dy-ry*dx);
if(perp < 0.42){ best=e; bestD=along; }
}
if(best) damageEnemy(best, dmg, dx, dy);
else if(wallD<20) spawnParticles(P.x+dx*(wallD-0.05), P.y+dy*(wallD-0.05), '#c8c8b0', 3, 1.2);
}
function damageEnemy(e, dmg, dx, dy){
e.hp -= dmg; e.painT = 0.18;
sfx('ehit');
spawnParticles(e.x, e.y, e.cls==='imp'?'#a03010':'#306010', 5, 1.6);
if(e.hp<=0 && !e.dead){
e.dead=true; e.dieT=0; kills++;
sfx('edie');
spawnParticles(e.x, e.y, '#701010', 14, 2.2);
if(kills>=totalEnemies) showMsg('ALL DEMONS PURGED — FIND THE EXIT PAD', '#8f8');
} else if(!e.dead){ e.state='chase'; e.atkCool=Math.max(e.atkCool, 0.4); }
}
function damagePlayer(d){
if(P.dead) return;
P.hp -= d; P.painT = 0.35; flashT = 0.3; shakeT = Math.max(shakeT, 0.02);
sfx('hurt');
if(P.hp<=0){
P.hp=0; P.dead=true; P.respawnT=1.6;
showMsg('YOU DIED', '#f44');
}
}
// ============================== UPDATE ==============================
function update(dt){
timeSec += dt;
// --- player ---
if(P.dead){
P.respawnT -= dt;
if(P.respawnT<=0){
if(P.lives===undefined){ P.lives=1; } // lives concept: infinite respawns, but track
P.x=START.x; P.y=START.y; P.hp=100; P.dead=false; P.painT=0;
P.ammo.bullets=Math.max(P.ammo.bullets,24); P.ammo.shells=Math.max(P.ammo.shells,4);
showMsg('RESPAWNED','#8cf');
}
} else {
const sprint = keys.ShiftLeft?1.55:1;
const spd = 3.1*sprint*dt;
let mx=0,my=0;
const ca=Math.cos(P.a), sa=Math.sin(P.a);
if(keys.KeyW){mx+=ca;my+=sa;} if(keys.KeyS){mx-=ca;my-=sa;}
if(keys.KeyA){mx+=sa;my-=ca;} if(keys.KeyD){mx-=sa;my+=ca;}
const ml=Math.hypot(mx,my);
if(ml>0){ moveCirc(P, mx/ml*spd, my/ml*spd, 0.28); bobT += dt*sprint*9; }
if(mouseDown) fire();
P.cool -= dt;
// pickups
for(let i=pickups.length-1;i>=0;i--){
const pk=pickups[i];
if(Math.hypot(pk.x-P.x, pk.y-P.y) < 0.55){
if(pk.kind==='medkit'){ if(P.hp>=100) continue; P.hp=Math.min(100,P.hp+25); showMsg('+25 HEALTH','#8f8'); }
else if(pk.kind==='ammo'){ P.ammo.bullets=Math.min(AMMO_MAX.bullets,P.ammo.bullets+24); showMsg('+24 BULLETS','#ec4'); }
else if(pk.kind==='shells'){ P.ammo.shells=Math.min(AMMO_MAX.shells,P.ammo.shells+8); showMsg('+8 SHELLS','#e84'); }
else if(pk.kind==='weapon1'){ P.owned[1]=true; P.ammo.shells=Math.min(AMMO_MAX.shells,P.ammo.shells+8); switchWeapon(1); showMsg('SHOTGUN ACQUIRED','#ee5'); }
else if(pk.kind==='weapon2'){ P.owned[2]=true; P.ammo.bullets=Math.min(AMMO_MAX.bullets,P.ammo.bullets+50); switchWeapon(2); showMsg('CHAINGUN ACQUIRED','#ee5'); }
else if(pk.kind==='exit'){
if(kills>=totalEnemies){ endScreen(true); return; }
else showMsg(`EXIT SEALED — ${totalEnemies-kills} DEMONS REMAIN`, '#f44');
continue;
}
if(pk.kind!=='exit'){ sfx('pickup'); pickups.splice(i,1); }
}
}
}
P.painT -= dt; flashT -= dt; shakeT -= dt; muzzleT -= dt;
// --- enemies ---
for(const e of enemies){
e.painT -= dt; e.atkT -= dt;
if(e.dead){ e.dieT += dt; continue; }
const dx=P.x-e.x, dy=P.y-e.y, d=Math.hypot(dx,dy);
const canSee = !P.dead && d < e.range && los(e.x,e.y,P.x,P.y);
if(canSee) e.state='chase';
else if(e.state==='chase' && d>e.range*1.4) e.state='idle';
if(e.state==='chase'){
if(d > e.melee*0.8){
const s=e.speed*dt;
moveCirc(e, dx/d*s, dy/d*s, 0.3);
e.frame = (e.ft+=dt*6)%2|0;
}
if(d < e.melee+0.3 && e.atkT<=0){
e.atkT = e.cls==='imp'? 1.1 : 1.6;
if(e.cls==='imp' && d>1.6){
projectiles.push({x:e.x,y:e.y,vx:dx/d*5.5,vy:dy/d*5.5,life:4,dmg:e.dmg});
e.attackAnim=0.25;
} else if(d < e.melee+0.4){
damagePlayer(e.dmg); e.attackAnim=0.25;
}
} else if(e.cls==='imp' && canSee && d<8 && d>2 && e.atkT<=0){
e.atkT = 1.4+Math.random();
projectiles.push({x:e.x,y:e.y,vx:dx/d*5.5,vy:dy/d*5.5,life:4,dmg:e.dmg});
e.attackAnim=0.25;
}
} else {
// wander
e.wanderT-=dt;
if(e.wanderT<=0){ e.wanderT=1+Math.random()*2; e.wanderA=Math.random()*6.28; }
moveCirc(e, Math.cos(e.wanderA)*e.speed*0.35*dt, Math.sin(e.wanderA)*e.speed*0.4*dt, 0.3);
e.frame=(e.ft+=dt*3)%2|0;
}
e.attackAnim = (e.attackAnim||0)-dt;
}
// --- projectiles ---
for(let i=projectiles.length-1;i>=0;i--){
const pr=projectiles[i];
pr.x+=pr.vx*dt; pr.y+=pr.vy*dt; pr.life-=dt;
if(pr.life<=0 || solid(pr.x,pr.y)){ spawnParticles(pr.x,pr.y,'#ff8030',6,1.5); projectiles.splice(i,1); continue; }
if(!P.dead && Math.hypot(pr.x-P.x,pr.y-P.y)<0.4){
damagePlayer(pr.dmg); projectiles.splice(i,1);
}
}
// --- particles ---
for(let i=particles.length-1;i>=0;i--){
const p=particles[i];
p.x+=p.vx*dt; p.y+=p.vy*dt; p.life-=dt;
if(p.life<=0) particles.splice(i,1);
}
}
// ============================== RENDER ==============================
const zbuf = new Float32Array(W);
const FOV = 0.9; // plane length
function render(){
const dirX=Math.cos(P.a), dirY=Math.sin(P.a);
const plX=-dirY*FOV, plY=dirX*FOV;
const horizon = H/2 + Math.sin(bobT)*4*(P.dead?0:1) + (shakeT>0? (Math.random()-0.5)*shakeT*400:0);
// ceiling / floor
const cg = ctx.createLinearGradient(0,0,0,H/2);
cg.addColorStop(0,'#1a1418'); cg.addColorStop(1,'#33262a');
ctx.fillStyle=cg; ctx.fillRect(0,0,W,H/2+2);
const fg = ctx.createLinearGradient(0,H/2,0,H);
fg.addColorStop(0,'#2a2224'); fg.addColorStop(1,'#4a3c34');
ctx.fillStyle=fg; ctx.fillRect(0,H/2,W,H/2+2);
// walls
for(let x=0;x<W;x+=2){
const camX = 2*x/W - 1;
const rdx = dirX + plX*camX, rdy = dirY + plY*camX;
const r = castRay(P.x,P.y,rdx,rdy);
const d = r.dist;
const hgt = Math.min(H*1.6, H/d);
const y0 = horizon - hgt/2;
const tex = TEX[r.tex]||TEX[1];
const tx = (r.wallX*64)|0;
const shade = Math.max(0.25, 1 - d/14) * (r.side?0.75:1);
ctx.drawImage(tex, tx, 0, 1, 64, x, y0, 2, hgt);
if(shade<1){ ctx.fillStyle=`rgba(0,0,0,${1-shade})`; ctx.fillRect(x,y0,2,hgt); }
zbuf[x]=zbuf[x+1]=d;
}
// ---- sprites (enemies, pickups, projectiles, particles) ----
const invDet = 1/(plX*dirY - dirX*plY);
const list = [];
for(const e of enemies) list.push({x:e.x,y:e.y,draw:()=>drawEnemy(e,invDet,dirX,dirY,plX,plY)});
for(const pk of pickups) list.push({x:pk.x,y:pk.y,draw:()=>drawPickup(pk,invDet,dirX,dirY,plX,plY)});
for(const pr of projectiles) list.push({x:pr.x,y:pr.y,draw:()=>drawBillboard(SPR.fireball,pr.x,pr.y,0.5,invDet,dirX,dirY,plX,plY,1)});
for(const p of particles) list.push({x:p.x,y:p.y,draw:()=>{
const t=worldToScreen(p.x,p.y,invDet,dirX,dirY,plX,plY);
if(t&&t.ty>0.1){ const sz=p.size/t.ty*3;
ctx.fillStyle=p.col; ctx.globalAlpha=Math.min(1,p.life*2);
ctx.fillRect(t.sx-sz/2, horizon + (H*0.5)/t.ty*0.4 - sz/2, sz, sz); ctx.globalAlpha=1; }
}});
list.sort((a,b)=> (Math.hypot(b.x-P.x,b.y-P.y)) - (Math.hypot(a.x-P.x,a.y-P.y)));
for(const it of list) it.draw();
// weapon
if(!P.dead) drawWeapon();
// damage flash
if(flashT>0){ ctx.fillStyle=`rgba(255,0,0,${Math.min(0.45,flashT*1.4)})`; ctx.fillRect(0,0,W,H); }
// low hp vignette
if(P.hp<35 && !P.dead){
const v = ctx.createRadialGradient(W/2,H/2,H*0.3,W/2,H/2,H*0.75);
v.addColorStop(0,'rgba(120,0,0,0)'); v.addColorStop(1,`rgba(140,0,0,${0.25+0.15*Math.sin(timeSec*6)})`);
ctx.fillStyle=v; ctx.fillRect(0,0,W,H);
}
// crosshair
if(!P.dead){
ctx.strokeStyle='rgba(255,255,255,0.7)'; ctx.lineWidth=1.5;
ctx.beginPath();
ctx.moveTo(W/2-8,H/2); ctx.lineTo(W/2-3,H/2); ctx.moveTo(W/2+3,H/2); ctx.lineTo(W/2+8,H/2);
ctx.moveTo(W/2,H/2-8); ctx.lineTo(W/2,H/2-3); ctx.moveTo(W/2,H/2+3); ctx.lineTo(W/2,H/2+8);
ctx.stroke();
}
drawHUD();
drawMinimap();
}
function worldToScreen(wx,wy,invDet,dirX,dirY,plX,plY){
const rx=wx-P.x, ry=wy-P.y;
const tx = invDet*(dirY*rx - dirX*ry);
const ty = invDet*(-plY*rx + plX*ry);
if(ty<=0.05) return null;
return { sx: (W/2)*(1+tx/ty), ty };
}
function drawBillboard(img, wx, wy, scale, invDet, dirX, dirY, plX, plY, vOff=0){
const t = worldToScreen(wx,wy,invDet,dirX,dirY,plX,plY);
if(!t) return;
const size = (H/t.ty)*scale;
const cx = t.sx, cy = horizon + vOff*(H/t.ty);
const half = size/2;
const x0 = Math.max(0, cx-half|0), x1 = Math.min(W-1, cx+half|0);
// check occlusion at center and edges
if(zbuf[Math.max(0,Math.min(W-1,cx|0))] < t.ty*0.98 && zbuf[x0] < t.ty*0.98 && zbuf[x1] < t.ty*0.98) return;
ctx.drawImage(img, x0, cy-half, x1-x0, size);
}
function drawEnemy(e, invDet, dirX, dirY, plX, plY){
let img;
if(e.dead){ img = SPR[e.cls+'Die'][Math.min(2, e.dieT*6|0)]; }
else if(e.attackAnim>0) img = SPR[e.cls+'Atk'];
else img = SPR[e.cls][e.frame|0];
const pain = e.painT>0;
const t = worldToScreen(e.x,e.y,invDet,dirX,dirY,plX,plY);
if(!t) return;
const scale = e.cls==='imp'?0.85:1.05;
const size = (H/t.ty)*scale;
const cy = horizon + (0.28*(H/t.ty)) - size/2 + size*0.06;
const half=size/2;
const x0=Math.max(0,cx0()), x1=Math.min(W-1,cx1());
function cx0(){ return t.sx-half|0; } function cx1(){ return t.sx+half|0; }
if(zbuf[Math.max(0,Math.min(W-1,t.sx|0))] < t.ty*0.95 && zbuf[Math.max(0,cx0())] < t.ty*0.95 && zbuf[Math.min(W-1,cx1())] < t.ty*0.95) return;
if(pain){ ctx.save(); ctx.filter='brightness(2.2) saturate(0.3)'; }
ctx.drawImage(img, x0, cy, x1-x0, size);
if(pain) ctx.restore();
// health bar
if(!e.dead && t.ty<8){
const bw = size*0.5, bh = Math.max(2,size*0.03);
ctx.fillStyle='#000'; ctx.fillRect(t.sx-bw/2, cy-bh*3, bw, bh);
ctx.fillStyle = e.hp>30?'#4c4':'#c44';
ctx.fillRect(t.sx-bw/2, cy-bh*3, bw*Math.max(0,e.hp)/(e.cls==='imp'?30:70), bh);
}
}
function drawPickup(pk, invDet, dirX, dirY, plX, plY){
if(pk.kind==='exit'){
// glowing pad
const t = worldToScreen(pk.x,pk.y,invDet,dirX,dirY,plX,plY);
if(!t) return;
const size=(H/t.ty)*0.9;
const pulse = 0.5+0.5*Math.sin(timeSec*4);
ctx.save(); ctx.globalAlpha = 0.35+0.3*pulse;
ctx.fillStyle='#3c88ff';
ctx.beginPath(); ctx.ellipse(t.sx, horizon+(0.5*(H/t.ty))*0.5+size*0.28, size*0.35, size*0.12, 0,0,7); ctx.fill();
ctx.restore();
drawBillboard(SPR.barrel, pk.x, pk.y, 0.55, invDet, dirX, dirY, plX, plY, 0.28);
return;
}
const bob = Math.sin(timeSec*3+pk.bob)*0.02;
let img, scale=0.32;
if(pk.kind==='medkit') img=SPR.medkit;
else if(pk.kind==='ammo') img=SPR.ammo;
else if(pk.kind==='shells') img=SPR.shells;
else if(pk.kind==='weapon1'){ img=SPR.shells; scale=0.45; }
else { img=SPR.chaingun; scale=0.45; }
drawBillboard(img, pk.x, pk.y, scale, invDet, dirX, dirY, plX, plY, 0.18+bob*0.03);
}
function drawWeapon(){
const w = WEAPONS[P.weapon];
const bob = Math.sin(bobT)*8, bob2 = Math.abs(Math.cos(bobT))*6;
const kick = P.cool>0 ? Math.min(1,P.cool/w.rate)*22 : 0;
const bx = W/2, by = H + bob2 + kick;
ctx.save();
if(muzzleT>0){ ctx.fillStyle='rgba(255,220,120,0.25)'; ctx.beginPath(); ctx.arc(bx, by-150, 60,0,7); ctx.fill(); }
if(P.weapon===0){ // pistol
ctx.fillStyle='#2c2c30'; ctx.fillRect(bx-14, by-110, 28, 110);
ctx.fillStyle='#3c3c42'; ctx.fillRect(bx-10, by-140, 20, 34);
ctx.fillStyle='#55555e'; ctx.fillRect(bx-6, by-150, 12, 12);
ctx.fillStyle='#1c1c20'; ctx.fillRect(bx-20, by-40, 40, 44);
} else if(P.weapon===1){ // shotgun
ctx.fillStyle='#4a2c14'; ctx.fillRect(bx-16, by-70, 32, 74);
ctx.fillStyle='#2c2c30'; ctx.fillRect(bx-24, by-130, 48, 64);
ctx.fillStyle='#111'; ctx.fillRect(bx-20, by-126, 16, 58); ctx.fillRect(bx+4, by-126, 16, 58);
} else { // chaingun
ctx.fillStyle='#33383c'; ctx.fillRect(bx-30, by-90, 60, 94);
ctx.fillStyle='#22262a';
const spin = (timeSec*30|0)%4;
for(let i=0;i<4;i++){ const off=((i+spin)%4)*9-18; ctx.fillRect(bx+off-3, by-130, 7, 44); }
ctx.fillStyle='#4c545a'; ctx.fillRect(bx-30, by-90, 60, 10);
}
ctx.restore();
}
function drawHUD(){
const h=64;
ctx.fillStyle='rgba(10,10,12,0.88)'; ctx.fillRect(0,H-h,W,h);
ctx.strokeStyle='#444'; ctx.beginPath(); ctx.moveTo(0,H-h); ctx.lineTo(W,H-h); ctx.stroke();
ctx.font='bold 20px "Courier New",monospace'; ctx.textBaseline='middle';
// health
ctx.fillStyle='#a33'; ctx.fillText('HP', 24, H-h/2-12);
const hpCol = P.hp>60?'#6d6':P.hp>30?'#dd4':'#f44';
ctx.fillStyle='#222'; ctx.fillRect(24, H-h/2+4, 180, 16);
ctx.fillStyle=hpCol; ctx.fillRect(24, H-h/2+4, 180*P.hp/100, 16);
ctx.fillStyle='#fff'; ctx.font='bold 14px monospace'; ctx.fillText(P.hp+'%', 214, H-h/2+12);
// ammo
const w = WEAPONS[P.weapon];
ctx.font='bold 20px monospace'; ctx.fillStyle='#ec4';
ctx.fillText(w.ammoType==='bullets'?'BULLETS':'SHELLS', 300, H-h/2-12);
ctx.font='bold 26px monospace'; ctx.fillStyle='#fff';
ctx.fillText(P.ammo[w.ammoType], 300, H-h/2+14);
// weapon slots
ctx.font='bold 14px monospace';
for(let i=0;i<3;i++){
const x=440+i*100;
ctx.fillStyle = P.weapon===i? '#ee5' : P.owned[i]? '#8a8':'#555';
ctx.fillText(`${i+1} ${WEAPONS[i].name}`, x, H-h/2-8);
if(P.owned[i]){ ctx.fillStyle='#666'; ctx.fillRect(x, H-h/2+6, 100, 5);
ctx.fillStyle='#ec4'; ctx.fillRect(x, H-h/2+6, 100*Math.min(1,P.ammo[WEAPONS[i].ammoType]/ (WEAPONS[i].ammoType==='shells'?50:200)), 5); }
}
// kills
ctx.font='bold 18px monospace'; ctx.fillStyle='#f66';
ctx.fillText(`DEMONS ${kills}/${totalEnemies}`, W-160, H-h/2-8);
ctx.fillStyle='#888'; ctx.font='bold 14px monospace';
ctx.fillText(`TIME ${timeSec.toFixed(0)}s`, W-160, H-h/2+16);
}
function drawMinimap(){
const s=4, ox=W-24*s-14, oy=14;
ctx.globalAlpha=0.75;
for(let y=0;y<MH;y++)for(let x=0;x<MW;x++){
if(map[y][x]>0){ ctx.fillStyle='#666'; ctx.fillRect(ox+x*s,oy+y*s,s,s); }
}
// exit
ctx.fillStyle = kills>=totalEnemies? (Math.sin(timeSec*6)>0?'#4f4':'#8f8') : '#f80';
ctx.fillRect(ox+EXIT.x*s-3, oy+EXIT.y*s-3, 6,6);
// enemies
ctx.fillStyle='#f33';
for(const e of enemies) if(!e.dead) ctx.fillRect(ox+e.x*s-1.5, oy+e.y*s-1.5, 3,3);
// player
ctx.fillStyle='#4cf';
ctx.beginPath(); ctx.arc(ox+P.x*s, oy+P.y*s, 3, 0, 7); ctx.fill();
ctx.strokeStyle='#4cf'; ctx.beginPath();
ctx.moveTo(ox+P.x*s, oy+P.y*s);
ctx.lineTo(ox+(P.x+Math.cos(P.a)*2)*s, oy+(P.y+Math.sin(P.a)*2)*s);
ctx.stroke();
ctx.globalAlpha=1;
}
// ============================== LOOP ==============================
let last = 0, horizon = H/2;
function frame(ts){
requestAnimationFrame(frame);
const dt = Math.min(0.05, (ts-last)/1000 || 0.016); last = ts;
if(gameState==='play'){
update(dt);
if(gameState!=='play') return;
}
if(P){
horizon = H/2 + Math.sin(bobT)*4 + (shakeT>0? (Math.random()-0.5)*shakeT*300 : 0);
render();
} else {
ctx.fillStyle='#000'; ctx.fillRect(0,0,W,H);
}
}
gameState='menu';
requestAnimationFrame(frame);
</script>
</body>
</html>
<!-- agent-meta {"model":"z-ai/glm-5.3-flash","provider":"openrouter","persona":"full","sessionId":"674dbb6f-db36-48e9-bb29-8dc857dd4a45","tokensIn":390010,"tokensOut":17303,"tokensTotal":407313,"cost":0.03305597,"turns":15,"toolCalls":15,"failedToolCalls":1,"timestamp":"2026-08-28T03:03:01.930Z"} -->