<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Pixel FPS — Mini DOOM/Quake-like</title>
<style>
html,body{height:100%;margin:0;background:#222;color:#ddd;font-family:monospace}
#game{display:block;margin:10px auto;background:#000; image-rendering: pixelated }
#ui{width:900px;margin:8px auto;display:flex;justify-content:space-between;align-items:center}
#hud{font-size:14px}
.controls{font-size:13px;color:#bbb}
button{background:#444;color:#fff;border:0;padding:6px 10px;border-radius:6px}
</style>
</head>
<body>
<div id="ui">
<div id="hud">Health: <span id="hp">100</span> Ammo: <span id="ammo">inf</span></div>
<div class="controls">W/A/S/D - move · Mouse click or Space - shoot · Click canvas to lock mouse</div>
<div><button id="reset">Reset</button></div>
</div>
<canvas id="game" width="1280" height="720"></canvas>
<script>
// Pixel FPS — simple raycaster with stationary enemies that shoot poorly
(() => {
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = false;
const W = canvas.width, H = canvas.height; // now 720p resolution
// map: 0 = empty, 1 = wall
const MAP = [
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
[1,0,0,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,0,1],
[1,0,0,0,0,0,1,0,1,0,0,0,1,0,1,0,0,0,0,1],
[1,0,0,0,0,0,1,0,1,0,0,0,1,0,1,0,0,0,0,1],
[1,0,0,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,0,1],
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
[1,0,1,1,1,0,0,0,0,1,1,0,0,0,0,1,1,1,0,1],
[1,0,1,0,1,0,0,0,0,1,1,0,0,0,0,1,0,1,0,1],
[1,0,1,0,1,0,0,0,0,1,1,0,0,0,0,1,0,1,0,1],
[1,0,1,1,1,0,0,0,0,1,1,0,0,0,0,1,1,1,0,1],
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
[1,0,0,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,0,1],
[1,0,0,0,0,0,1,0,1,0,0,0,1,0,1,0,0,0,0,1],
[1,0,0,0,0,0,1,0,1,0,0,0,1,0,1,0,0,0,0,1],
[1,0,0,0,0,0,1,1,1,0,0,0,1,1,1,0,0,0,0,1],
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]
];
const TILE = 64; // world tile size
const MAP_W = MAP[0].length, MAP_H = MAP.length;
// Player
const player = {
x: TILE * 2.5,
y: TILE * 2.5,
angle: 0,
fov: Math.PI / 3,
speed: 120, // units per second
health: 100
};
// Enemies (stationary)
const enemies = [
{x: TILE*8.2, y: TILE*2.8, hp: 30, lastShot:0},
{x: TILE*6.5, y: TILE*5.2, hp: 30, lastShot:0},
{x: TILE*3.8, y: TILE*6.8, hp: 30, lastShot:0}
];
const bullets = []; // enemy bullets
const playerShots = [];
// input
const keys = {};
window.addEventListener('keydown', e => keys[e.key.toLowerCase()] = true);
window.addEventListener('keyup', e => keys[e.key.toLowerCase()] = false);
// pointer lock for mouse aiming
canvas.addEventListener('click', ()=>{
canvas.requestPointerLock?.();
});
document.addEventListener('pointerlockchange', ()=>{});
document.addEventListener('mousemove', (e)=>{
if(document.pointerLockElement === canvas){
player.angle = (player.angle + e.movementX * 0.002) % (Math.PI * 2);
}
});
// shooting
let lastFire = 0;
const fireRate = 0.18; // seconds between shots
function reset(){
player.x = TILE*2.5; player.y = TILE*2.5; player.angle = 0; player.health = 100;
enemies.forEach(en=>{en.hp = 30; en.lastShot=0});
bullets.length = 0; playerShots.length = 0;
document.getElementById('hp').textContent = player.health;
}
document.getElementById('reset').onclick = reset;
// utilities
function inBounds(x,y){
const mx = Math.floor(x/TILE), my = Math.floor(y/TILE);
return my>=0 && my<MAP_H && mx>=0 && mx<MAP_W;
}
function isWall(x,y){
if(!inBounds(x,y)) return true;
return MAP[Math.floor(y/TILE)][Math.floor(x/TILE)] !== 0;
}
// raycasting render
function castRays(){
const numRays = W; // 1 ray per screen column for crisp pixel look
const halfFov = player.fov/2;
const projPlane = (W/2) / Math.tan(halfFov);
for(let col=0; col<numRays; col++){
const rayScreenPos = (col - numRays/2);
const rayAngle = player.angle + Math.atan(rayScreenPos/projPlane);
const ray = castSingleRay(rayAngle);
// draw vertical slice
const dist = ray.distance || 1;
const corrected = dist * Math.cos(rayAngle - player.angle);
const height = Math.min(H, (TILE*projPlane)/corrected);
const top = Math.floor(H/2 - height/2);
// simple shade based on distance
let shade = Math.max(0.15, 1 - corrected/800);
let color = ray.wallType===1 ? `rgb(${Math.floor(200*shade)},${Math.floor(100*shade)},${Math.floor(60*shade)})` : `rgb(${Math.floor(120*shade)},${Math.floor(120*shade)},${Math.floor(120*shade)})`;
ctx.fillStyle = color;
ctx.fillRect(col, top, 1, Math.floor(height));
// ceiling and floor columns (pixelated)
ctx.fillStyle = '#222';
if(top>0) ctx.fillRect(col, 0, 1, top);
ctx.fillStyle = '#333';
if(top+height < H) ctx.fillRect(col, top+height, 1, H - (top+height));
}
}
function castSingleRay(angle){
let sin = Math.sin(angle), cos = Math.cos(angle);
let x = player.x, y = player.y;
const step = 4; // step size smaller -> more accurate
for(let i=0;i<600;i+=step){
const nx = x + cos * i; const ny = y + sin * i;
if(isWall(nx,ny)){
return {distance: Math.hypot(nx-player.x, ny-player.y), wallType:1};
}
}
return {distance: 10000, wallType:0};
}
// render enemies (simple sprite scaling)
function renderEnemies(){
const projPlane = (W/2) / Math.tan(player.fov/2);
const visible = [];
enemies.forEach((e,idx)=>{
if(e.hp<=0) return;
const dx = e.x - player.x, dy = e.y - player.y;
const dist = Math.hypot(dx,dy);
let angToEnemy = Math.atan2(dy,dx);
let relAng = angToEnemy - player.angle;
// normalize
while(relAng > Math.PI) relAng -= Math.PI*2;
while(relAng < -Math.PI) relAng += Math.PI*2;
const inFov = Math.abs(relAng) < player.fov/2 + 0.2;
if(!inFov) return;
// simple occlusion test: cast a ray toward enemy and see if wall distance < enemy dist
const ray = castSingleRay(player.angle + relAng);
if(ray.distance < dist - 10) return; // wall in front
// size and screen x
const size = Math.min(3000, (TILE*projPlane)/dist);
const screenX = Math.tan(relAng) * projPlane + W/2;
visible.push({idx, dist, size, screenX});
});
// draw from far to near
visible.sort((a,b)=>b.dist-a.dist);
visible.forEach(v=>{
const e = enemies[v.idx];
// draw a simple pixelated enemy (rectangle with eyes)
const w = Math.max(6, v.size*0.6);
const h = Math.max(6, v.size);
const left = Math.floor(v.screenX - w/2);
const top = Math.floor(H/2 - h/2);
// body
ctx.fillStyle = '#880000';
ctx.fillRect(left, top, Math.floor(w), Math.floor(h));
// eyes
ctx.fillStyle = '#ffcc00';
ctx.fillRect(left+Math.floor(w*0.25), top+Math.floor(h*0.2), Math.max(1,Math.floor(w*0.15)), Math.max(1,Math.floor(h*0.12)));
ctx.fillRect(left+Math.floor(w*0.6), top+Math.floor(h*0.2), Math.max(1,Math.floor(w*0.15)), Math.max(1,Math.floor(h*0.12)));
});
}
// enemy shooting (slow + inaccurate)
function enemyBehavior(dt){
enemies.forEach(e=>{
if(e.hp<=0) return;
e.lastShot += dt;
if(e.lastShot > 2.0 + Math.random()*2.5){ // slow, irregular
e.lastShot = 0;
// poor accuracy: add angle jitter depending on distance
const dx = player.x - e.x, dy = player.y - e.y;
const baseAngle = Math.atan2(dy,dx);
const dist = Math.hypot(dx,dy);
const jitter = (0.6 + Math.random()*1.2) * (dist/400); // more distance => worse accuracy
const aimAngle = baseAngle + (Math.random()*2-1)*jitter;
const speed = 200 + Math.random()*100;
bullets.push({x: e.x, y: e.y, vx: Math.cos(aimAngle)*speed, vy: Math.sin(aimAngle)*speed, life: 5});
}
});
}
// update bullets
function updateBullets(dt){
for(let i=bullets.length-1;i>=0;i--){
const b = bullets[i];
b.x += b.vx * dt; b.y += b.vy * dt; b.life -= dt;
// collision with walls
if(isWall(b.x,b.y)) { bullets.splice(i,1); continue; }
// collision with player
const d = Math.hypot(b.x-player.x, b.y-player.y);
if(d < 16){
player.health = Math.max(0, player.health - 8);
document.getElementById('hp').textContent = player.health;
bullets.splice(i,1);
continue;
}
if(b.life <= 0) bullets.splice(i,1);
}
}
// draw bullets (as tiny pixels in the world projection) — we draw simple HUD crosshair for clarity
function drawBullets(){
bullets.forEach(b=>{
// project bullet similarly to enemies
const dx = b.x - player.x, dy = b.y - player.y;
const dist = Math.hypot(dx,dy);
const ang = Math.atan2(dy,dx);
let rel = ang - player.angle;
while(rel > Math.PI) rel -= Math.PI*2;
while(rel < -Math.PI) rel += Math.PI*2;
if(Math.abs(rel) > player.fov/2) return;
// occlusion
const ray = castSingleRay(player.angle + rel);
if(ray.distance < dist - 8) return;
const projPlane = (W/2) / Math.tan(player.fov/2);
const size = Math.max(2, (6*projPlane)/dist);
const screenX = Math.tan(rel) * projPlane + W/2;
const screenY = H/2;
ctx.fillStyle = '#ff9900';
ctx.fillRect(Math.floor(screenX-size/2), Math.floor(screenY-size/2), Math.ceil(size), Math.ceil(size));
});
}
// player shooting (ray test vs enemies and walls)
function playerShoot(){
const now = performance.now()/1000;
if(now - lastFire < fireRate) return;
lastFire = now;
// play simple muzzle flash by pushing a short-lived shot for visuals
playerShots.push({time:0.06});
// check if any enemy is hit by a narrow cone
const rayAngle = player.angle;
// find closest enemy in the center ray
let hit = null;
let hitDist = 1e9;
enemies.forEach((e,idx)=>{
if(e.hp<=0) return;
const dx = e.x - player.x, dy = e.y - player.y;
const dist = Math.hypot(dx,dy);
const ang = Math.atan2(dy,dx);
let rel = ang - player.angle;
while(rel > Math.PI) rel -= Math.PI*2;
while(rel < -Math.PI) rel += Math.PI*2;
// tight cone for rifle
if(Math.abs(rel) < 0.06){
// check wall occlusion
const ray = castSingleRay(player.angle + rel);
if(ray.distance > dist - 6){
if(dist < hitDist){ hit = e; hitDist = dist; }
}
}
});
if(hit){
hit.hp -= 16;
if(hit.hp <=0){
// enemy eliminated message
const remaining = enemies.filter(en=>en.hp>0).length - 1;
killMsg = (remaining + 1) + ' enemies left';
lastKillMsgTime = 1.5; /* dead */ }
}
}
// draw weapon (pixelated rifle) and muzzle flash
function drawWeapon(dt){
const w = 220, h = 140;
const x = Math.floor(W/2 - w/2), y = H - h;
// simple low-res rifle as rectangles
// shadow
ctx.fillStyle = '#000'; ctx.fillRect(x+6, y+6, w, h);
ctx.fillStyle = '#222'; ctx.fillRect(x, y, w, h);
ctx.fillStyle = '#444'; ctx.fillRect(x+30, y+30, w-60, 30);
ctx.fillStyle = '#666'; ctx.fillRect(x+10, y+60, 40, 20);
// muzzle flash if recently shot
if(playerShots.length){
ctx.fillStyle = '#ffcc33';
ctx.fillRect(x+w-40, y+40, 24, 24);
}
}
// --- Pickups ---
const pickups = [
{x:TILE*4.5,y:TILE*4.5,type:'health',taken:false},
{x:TILE*10.5,y:TILE*3.5,type:'health',taken:false},
{x:TILE*14.5,y:TILE*8.5,type:'health',taken:false}
];
// extra enemies
enemies.push(
{x:TILE*12.5,y:TILE*2.5,hp:30,lastShot:0},
{x:TILE*15.5,y:TILE*6.5,hp:30,lastShot:0},
{x:TILE*5.5,y:TILE*10.5,hp:30,lastShot:0}
);
// stopwatch
let lastKillMsgTime = 0;
let killMsg = '';
let missionTime = 0;
let gameStarted = false;
let missionComplete = false;
function updatePickups(dt){
pickups.forEach(p=>{
if(p.taken) return;
if(Math.hypot(p.x-player.x,p.y-player.y)<20){
p.taken = true;
if(p.type==='health'){
player.health = Math.min(100, player.health+30);
document.getElementById('hp').textContent = player.health;
}
}
});
}
function drawPickups(){
pickups.forEach(p=>{
if(p.taken) return;
const dx=p.x-player.x, dy=p.y-player.y;
const dist=Math.hypot(dx,dy);
const ang=Math.atan2(dy,dx);
let rel=ang-player.angle;
while(rel>Math.PI)rel-=Math.PI*2;
while(rel<-Math.PI)rel+=Math.PI*2;
if(Math.abs(rel)>player.fov/2) return;
const ray=castSingleRay(player.angle+rel);
if(ray.distance<dist-8)return;
const projPlane=(W/2)/Math.tan(player.fov/2);
const size=Math.max(4,(10*projPlane)/dist);
const screenX=Math.tan(rel)*projPlane+W/2;
const screenY=H/2;
ctx.fillStyle='#00ff00';
ctx.fillRect(screenX-size/2,screenY-size/2,size,size);
});
}
function checkMission(){
if(!missionComplete && enemies.every(e=>e.hp<=0)){
missionComplete=true;
}
}
// game loop
let last = performance.now();
function loop(now){
if(!gameStarted){
ctx.fillStyle='#000';ctx.fillRect(0,0,W,H);
ctx.fillStyle='#fff';ctx.font='36px monospace';
ctx.fillText('PIXEL FPS', W/2-100, H/2-60);
ctx.font='20px monospace';
ctx.fillText('Click to Start', W/2-70, H/2);
requestAnimationFrame(loop);
return;
}
const dt = Math.min(0.05, (now - last)/1000);
last = now;
// input movement
let mx = 0, my = 0;
if(keys['w']) my += 1; if(keys['s']) my -= 1;
if(keys['a']) mx -= 1; if(keys['d']) mx += 1;
// Q/E rotate
// Arrow-key rotation
if(keys['arrowleft']) player.angle -= 2*dt;
if(keys['arrowright']) player.angle += 2*dt;
// strafing while turning is allowed via A/D above (move left/right)
if(mx !== 0 || my !== 0){
// move relative to view
const forward = my * player.speed * dt;
const strafe = mx * player.speed * dt;
const nx = player.x + Math.cos(player.angle) * forward + Math.cos(player.angle + Math.PI/2) * strafe;
const ny = player.y + Math.sin(player.angle) * forward + Math.sin(player.angle + Math.PI/2) * strafe;
// collision simple
if(!isWall(nx, player.y)) player.x = nx;
if(!isWall(player.x, ny)) player.y = ny;
}
// shooting inputs
if(keys[' '] || keys['mouse']) playerShoot();
// update behaviors
enemyBehavior(dt);
updateBullets(dt);
for(let i=playerShots.length-1;i>=0;i--){ playerShots[i].time -= dt; if(playerShots[i].time<=0) playerShots.splice(i,1);}
// clear
ctx.fillStyle = '#111'; ctx.fillRect(0,0,W,H);
// raycast walls
castRays();
// enemies
if(lastKillMsgTime>0){
lastKillMsgTime -= dt;
ctx.fillStyle='rgba(0,0,0,0.5)';
ctx.fillRect(W/2-150, H/2-80, 300, 50);
ctx.fillStyle='#fff';
ctx.font='24px monospace';
ctx.fillText(killMsg, W/2-120, H/2-45);
}
renderEnemies();
// bullets
drawBullets();
// weapon + HUD
drawWeapon(dt);
// draw timer
ctx.fillStyle='#fff';
ctx.font='16px monospace';
ctx.fillText('Time: '+missionTime.toFixed(2), 20, 30);
// draw crosshair
ctx.fillStyle = 'rgba(200,200,200,0.7)';
ctx.fillRect(W/2-1, H/2-6, 2, 12);
ctx.fillRect(W/2-6, H/2-1, 12, 2);
if(!missionComplete){ missionTime+=dt; }
checkMission();
if(missionComplete){
ctx.fillStyle='rgba(0,0,0,0.6)';
ctx.fillRect(0,0,W,H);
ctx.fillStyle='#fff';
ctx.font='32px monospace';
ctx.fillText('Mission Accomplished!', W/2-180, H/2-40);
ctx.fillText('Good Job! Time: '+missionTime.toFixed(2)+'s', W/2-200, H/2+10);
return;
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
// mouse button maps
canvas.addEventListener('mousedown', e=>{ keys['mouse']=true; gameStarted=true; });
canvas.addEventListener('mouseup', e=>{ keys['mouse']=false; });
canvas.addEventListener('mouseup', e=>{ keys['mouse']=false; });
// small instruction overlay in console
console.log('Controls: W/A/S/D move, Q/E rotate, Space or Click shoot. Click canvas to lock mouse.');
})();
</script>
</body>
</html>