JS-Doom: Retro Raycaster
Experience a classic pseudo-3D first-person shooter directly in your vault. Inspired by the legendary 1993 classic, this self-contained raycaster features procedural textures, audio synthesis, animated sprites, interactive doors, pathfinding enemies, and mobile-friendly touch controls.
<div class="doom-container">
<!-- Title Screen / Menu Overlay -->
<div id="menu" class="overlay">
<div class="menu-content">
<h1 class="doom-title">JS-DOOM</h1>
<p class="doom-subtitle">KNEE-DEEP IN THE CODE</p>
<button id="start-btn" class="doom-btn">START GAME</button>
<div class="instructions">
<p><strong>Keyboard:</strong> WASD / Arrows to move/turn, Space to Shoot, E to Open Doors, Q or 1/2 to Swap Weapons</p>
<p><strong>Touch:</strong> Use virtual controls below</p>
</div>
</div>
</div>
<!-- Game Over / Victory Overlay -->
<div id="gameover" class="overlay hidden">
<div class="menu-content">
<h1 id="gameover-title" class="doom-title text-red">YOU DIED</h1>
<p id="gameover-subtitle">Press to restart</p>
<button id="restart-btn" class="doom-btn">RETRY</button>
</div>
</div>
<!-- Game Screen (Widescreen 16:9) -->
<div class="game-viewport">
<canvas id="gameCanvas" width="640" height="360"></canvas>
<!-- Crosshair -->
<div class="crosshair"></div>
<!-- Screen Flash -->
<div id="flash" class="flash-overlay"></div>
</div>
<!-- Status Bar (Retro HUD) -->
<div class="hud">
<div class="hud-stat">
<span class="hud-label">AMMO</span>
<span id="hud-ammo" class="hud-val">50</span>
</div>
<div class="hud-stat">
<span class="hud-label">HEALTH</span>
<span id="hud-health" class="hud-val">100%</span>
</div>
<div class="hud-face-container">
<canvas id="hudFace" width="40" height="40"></canvas>
</div>
<div class="hud-stat">
<span class="hud-label">ARMOR</span>
<span id="hud-armor" class="hud-val">50%</span>
</div>
<div class="hud-stat">
<span class="hud-label">SECTOR</span>
<span id="hud-sector" class="hud-val">1/2</span>
</div>
<div class="hud-stat">
<span class="hud-label">WEAPON</span>
<span id="hud-weapon" class="hud-val">PISTOL</span>
</div>
</div>
<!-- Touch Controls -->
<div class="touch-controls">
<div class="dpad">
<button id="btn-up" class="ctrl-btn">▲</button>
<div class="dpad-row">
<button id="btn-left" class="ctrl-btn">◀</button>
<button id="btn-right" class="ctrl-btn">▶</button>
</div>
<button id="btn-down" class="ctrl-btn">▼</button>
</div>
<div class="actions">
<button id="btn-use" class="ctrl-btn action-btn font-sm">USE (E)</button>
<button id="btn-fire" class="ctrl-btn action-btn fire-btn">FIRE</button>
<button id="btn-weapon" class="ctrl-btn action-btn font-sm">SWAP</button>
</div>
</div>
</div>
<style>
.doom-container {
--doom-red: #ff0000;
--doom-gold: #ffcc00;
--doom-gray: #333333;
--doom-hud-bg: #1c1c1c;
font-family: 'Courier New', Courier, monospace;
background: #000;
color: #fff;
user-select: none;
-webkit-user-select: none;
position: relative;
width: 100%;
max-width: 1000px;
margin: 0 auto;
border: 4px solid var(--doom-gray);
box-shadow: 0 10px 30px rgba(0,0,0,0.8);
overflow: hidden;
}
.overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.85);
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
transition: opacity 0.3s ease;
}
.overlay.hidden {
display: none;
opacity: 0;
pointer-events: none;
}
.menu-content {
padding: 20px;
}
.doom-title {
font-size: 3rem;
font-weight: 900;
color: var(--doom-red);
text-shadow: 3px 3px 0px var(--doom-gold);
margin: 0 0 5px 0;
letter-spacing: 4px;
font-family: Arial, sans-serif;
}
.text-red {
color: var(--doom-red) !important;
text-shadow: 2px 2px 0px #000 !important;
}
.doom-subtitle {
font-size: 1rem;
color: var(--doom-gold);
margin: 0 0 30px 0;
letter-spacing: 2px;
}
.doom-btn {
background: #5a0000;
border: 3px solid var(--doom-red);
color: #fff;
font-family: inherit;
font-size: 1.2rem;
font-weight: bold;
padding: 10px 25px;
cursor: pointer;
box-shadow: 0 4px 0px #300000;
transition: all 0.1s ease;
}
.doom-btn:active {
transform: translateY(4px);
box-shadow: none;
}
.instructions {
margin-top: 30px;
font-size: 0.8rem;
color: #888;
line-height: 1.4;
max-width: 500px;
}
.game-viewport {
position: relative;
width: 100%;
padding-bottom: 56.25%; /* 16:9 widescreen aspect ratio */
background: #000;
}
#gameCanvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
image-rendering: pixelated;
}
.crosshair {
position: absolute;
top: 50%;
left: 50%;
width: 6px;
height: 6px;
transform: translate(-50%, -50%);
border: 1px solid rgba(255, 0, 0, 0.4);
border-radius: 50%;
pointer-events: none;
}
.flash-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 0, 0, 0);
pointer-events: none;
z-index: 5;
transition: background-color 0.05s ease-out;
}
/* Retro HUD styles */
.hud {
display: grid;
grid-template-columns: 1fr 1fr 60px 1fr 1fr 1.2fr;
background: var(--doom-hud-bg);
border-top: 4px solid var(--doom-gray);
padding: 5px 10px;
align-items: center;
box-sizing: border-box;
}
.hud-stat {
display: flex;
flex-direction: column;
align-items: center;
border-right: 2px solid #2d2d2d;
}
.hud-stat:last-child {
border-right: none;
}
.hud-label {
font-size: 0.6rem;
color: #666;
font-weight: bold;
margin-bottom: 2px;
}
.hud-val {
font-size: 1.1rem;
font-weight: 900;
color: var(--doom-red);
text-shadow: 1px 1px 0px #000;
}
.hud-face-container {
display: flex;
justify-content: center;
align-items: center;
border-right: 2px solid #2d2d2d;
height: 100%;
}
#hudFace {
image-rendering: pixelated;
background: #000;
border: 1px solid #444;
}
/* Touch controls */
.touch-controls {
display: flex;
justify-content: space-between;
padding: 10px;
background: #111;
border-top: 2px solid var(--doom-gray);
}
.dpad {
display: flex;
flex-direction: column;
align-items: center;
width: 120px;
}
.dpad-row {
display: flex;
justify-content: space-between;
width: 100%;
}
.ctrl-btn {
background: #222;
border: 2px solid #444;
color: #fff;
width: 38px;
height: 38px;
border-radius: 4px;
font-size: 1.1rem;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
touch-action: manipulation;
}
.ctrl-btn:active {
background: var(--doom-red);
border-color: var(--doom-gold);
}
.actions {
display: flex;
gap: 8px;
align-items: center;
}
.action-btn {
width: 55px;
height: 55px;
border-radius: 50%;
}
.font-sm {
font-size: 0.7rem !important;
}
.fire-btn {
width: 65px;
height: 65px;
background: #800;
border-color: var(--doom-red);
font-size: 0.9rem;
}
@media(min-width: 480px) {
.hud-val {
font-size: 1.4rem;
}
}
</style>
<script>
// Simple retro synthesizer for sound effects (Web Audio API)
class SoundSynth {
constructor() {
this.ctx = null;
}
init() {
if (!this.ctx) {
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
}
}
playShoot() {
this.init();
if (!this.ctx) return;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(300, this.ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(40, this.ctx.currentTime + 0.15);
gain.gain.setValueAtTime(0.3, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + 0.15);
osc.start();
osc.stop(this.ctx.currentTime + 0.15);
}
playExplosion() {
this.init();
if (!this.ctx) return;
const bufferSize = this.ctx.sampleRate * 0.25;
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
const noise = this.ctx.createBufferSource();
noise.buffer = buffer;
const filter = this.ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(400, this.ctx.currentTime);
filter.frequency.exponentialRampToValueAtTime(10, this.ctx.currentTime + 0.25);
const gain = this.ctx.createGain();
gain.gain.setValueAtTime(0.4, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + 0.25);
noise.connect(filter);
filter.connect(gain);
gain.connect(this.ctx.destination);
noise.start();
}
playPain() {
this.init();
if (!this.ctx) return;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(120, this.ctx.currentTime);
osc.frequency.linearRampToValueAtTime(60, this.ctx.currentTime + 0.2);
gain.gain.setValueAtTime(0.3, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + 0.2);
osc.start();
osc.stop(this.ctx.currentTime + 0.2);
}
playItem() {
this.init();
if (!this.ctx) return;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.type = 'sine';
osc.frequency.setValueAtTime(440, this.ctx.currentTime);
osc.frequency.setValueAtTime(880, this.ctx.currentTime + 0.08);
gain.gain.setValueAtTime(0.15, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + 0.2);
osc.start();
osc.stop(this.ctx.currentTime + 0.2);
}
playDoor() {
this.init();
if (!this.ctx) return;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.type = 'triangle';
osc.frequency.setValueAtTime(80, this.ctx.currentTime);
osc.frequency.linearRampToValueAtTime(100, this.ctx.currentTime + 0.5);
gain.gain.setValueAtTime(0.2, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + 0.5);
osc.start();
osc.stop(this.ctx.currentTime + 0.5);
}
}
const sfx = new SoundSynth();
// Multi-Sector / Level definitions
const sectorsData = {
1: {
map: [
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
[1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1],
[1,0,1,1,0,0,3,0,0,2,2,0,2,2,0,1],
[1,0,1,0,0,0,1,0,0,2,0,0,0,2,0,1],
[1,0,1,0,1,1,1,0,0,2,0,0,0,2,0,1],
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
[1,0,0,0,0,0,0,0,0,2,2,3,2,2,0,1],
[1,0,1,1,1,1,0,0,0,0,0,0,0,0,0,1],
[1,0,1,0,0,1,0,0,0,0,0,0,0,0,0,1],
[1,0,1,0,0,1,0,0,1,1,1,1,1,1,0,1],
[1,0,0,0,0,0,0,0,1,0,0,0,0,1,0,1],
[1,0,2,2,0,2,0,0,1,0,4,0,0,1,0,1],
[1,0,2,0,0,2,0,0,1,0,0,0,0,3,0,1],
[1,0,2,0,0,2,0,0,1,1,1,1,1,1,0,1],
[1,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]
],
sprites: [
{ x: 5.5, y: 5.5, texture: 'cacodemon', health: 30, dead: false, type: 'enemy', speed: 0.02 },
{ x: 3.5, y: 3.5, texture: 'imp', health: 40, dead: false, type: 'enemy', speed: 0.015 },
{ x: 12.5, y: 3.5, texture: 'imp', health: 40, dead: false, type: 'enemy', speed: 0.015 },
{ x: 12.5, y: 11.5, texture: 'cacodemon', health: 30, dead: false, type: 'enemy', speed: 0.02 },
{ x: 10.5, y: 12.5, texture: 'imp', health: 40, dead: false, type: 'enemy', speed: 0.015 },
{ x: 8.5, y: 8.5, texture: 'cacodemon', health: 30, dead: false, type: 'enemy', speed: 0.02 },
{ x: 4.5, y: 12.5, texture: 'medkit', type: 'item' },
{ x: 5.5, y: 12.5, texture: 'ammo', type: 'item' },
{ x: 14.5, y: 1.5, texture: 'medkit', type: 'item' },
{ x: 14.5, y: 2.5, texture: 'ammo', type: 'item' }
],
startX: 1.5,
startY: 1.5
},
2: {
map: [
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
[1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1],
[1,0,2,0,3,0,4,0,3,0,2,0,3,0,2,1],
[1,0,2,0,1,0,0,0,1,0,2,0,1,0,0,1],
[1,0,0,0,1,1,1,0,1,1,1,0,1,1,0,1],
[1,1,3,1,1,0,0,0,0,0,1,1,1,0,0,1],
[1,0,0,0,1,0,2,2,2,0,1,0,0,0,0,1],
[1,0,4,0,3,0,2,0,2,0,3,0,4,0,0,1],
[1,0,0,0,1,0,2,2,2,0,1,0,0,0,0,1],
[1,1,3,1,1,0,0,0,0,0,1,1,3,1,1,1],
[1,0,0,0,1,1,1,0,1,1,1,0,0,0,0,1],
[1,0,2,0,1,0,0,0,1,0,2,0,1,0,2,1],
[1,0,2,0,3,0,4,0,3,0,2,0,3,0,2,1],
[1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1],
[1,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]
],
sprites: [
{ x: 2.5, y: 2.5, texture: 'cacodemon', health: 30, dead: false, type: 'enemy', speed: 0.02 },
{ x: 13.5, y: 2.5, texture: 'cacodemon', health: 30, dead: false, type: 'enemy', speed: 0.02 },
{ x: 7.5, y: 7.5, texture: 'imp', health: 50, dead: false, type: 'enemy', speed: 0.025 },
{ x: 7.5, y: 6.5, texture: 'imp', health: 50, dead: false, type: 'enemy', speed: 0.025 },
{ x: 7.5, y: 8.5, texture: 'imp', health: 50, dead: false, type: 'enemy', speed: 0.025 },
{ x: 2.5, y: 13.5, texture: 'cacodemon', health: 35, dead: false, type: 'enemy', speed: 0.02 },
{ x: 13.5, y: 13.5, texture: 'cacodemon', health: 35, dead: false, type: 'enemy', speed: 0.025 },
{ x: 5.5, y: 1.5, texture: 'medkit', type: 'item' },
{ x: 9.5, y: 1.5, texture: 'ammo', type: 'item' },
{ x: 5.5, y: 14.5, texture: 'medkit', type: 'item' },
{ x: 9.5, y: 14.5, texture: 'ammo', type: 'item' }
],
startX: 7.5,
startY: 14.5
}
};
// Canvas configuration
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const viewWidth = canvas.width;
const viewHeight = canvas.height;
// Player State
let currentSector = 1;
let worldMap = JSON.parse(JSON.stringify(sectorsData[1].map));
let posX = sectorsData[1].startX;
let posY = sectorsData[1].startY;
let dirX = 1.0, dirY = 0.0; // Direction vector
let planeX = 0.0, planeY = 0.66; // Camera plane
let health = 100;
let armor = 50;
let ammo = 50;
let currentWeapon = 'PISTOL';
let isGameOver = false;
let gameStarted = false;
let weaponAnimFrame = 0;
let isShooting = false;
let moveSpeed = 0.07;
let rotSpeed = 0.04;
// Standardized lowercase controls map to prevent case mismatch bugs
const keys = {
w: false, s: false, a: false, d: false,
arrowup: false, arrowdown: false, arrowleft: false, arrowright: false,
e: false
};
// Multiple active door management (independent doors)
let openDoors = [];
// Sprites (Enemies & Collectibles) loaded per sector
let sprites = [];
function loadSector(sectorNum) {
currentSector = sectorNum;
const sector = sectorsData[sectorNum];
worldMap = JSON.parse(JSON.stringify(sector.map));
sprites = JSON.parse(JSON.stringify(sector.sprites));
posX = sector.startX;
posY = sector.startY;
dirX = 1.0;
dirY = 0.0;
planeX = 0.0;
planeY = 0.66;
openDoors = [];
document.getElementById('hud-sector').textContent = `${currentSector}/2`;
updateHUD();
}
// Procedural Textures Cache
const texSize = 64;
const textures = {};
function generateTextures() {
// Wall 1: Gray Tech wall
const c1 = document.createElement('canvas'); c1.width = texSize; c1.height = texSize;
const ctx1 = c1.getContext('2d');
ctx1.fillStyle = '#444'; ctx1.fillRect(0, 0, texSize, texSize);
ctx1.strokeStyle = '#222'; ctx1.lineWidth = 2;
ctx1.strokeRect(0, 0, texSize, texSize);
ctx1.strokeRect(8, 8, texSize - 16, texSize - 16);
ctx1.fillStyle = '#0f0'; ctx1.fillRect(12, 12, 6, 6); // green light
ctx1.fillStyle = '#f00'; ctx1.fillRect(texSize - 18, 12, 6, 6); // red light
textures[1] = c1;
// Wall 2: Red Brick Wall
const c2 = document.createElement('canvas'); c2.width = texSize; c2.height = texSize;
const ctx2 = c2.getContext('2d');
ctx2.fillStyle = '#7a2214'; ctx2.fillRect(0, 0, texSize, texSize);
ctx2.strokeStyle = '#3a110a'; ctx2.lineWidth = 1.5;
for (let y = 0; y < texSize; y += 16) {
ctx2.beginPath(); ctx2.moveTo(0, y); ctx2.lineTo(texSize, y); ctx2.stroke();
const offset = (y / 16) % 2 === 0 ? 0 : 16;
for (let x = offset; x < texSize; x += 32) {
ctx2.beginPath(); ctx2.moveTo(x, y); ctx2.lineTo(x, y + 16); ctx2.stroke();
}
}
textures[2] = c2;
// Door (Blue Door)
const c3 = document.createElement('canvas'); c3.width = texSize; c3.height = texSize;
const ctx3 = c3.getContext('2d');
ctx3.fillStyle = '#223366'; ctx3.fillRect(0, 0, texSize, texSize);
ctx3.fillStyle = '#ffcc00'; ctx3.fillRect(20, 10, 24, 44);
ctx3.fillStyle = '#111'; ctx3.fillRect(24, 14, 16, 36);
textures[3] = c3;
// Pillar (Metal cylinder / pillar block)
const c4 = document.createElement('canvas'); c4.width = texSize; c4.height = texSize;
const ctx4 = c4.getContext('2d');
ctx4.fillStyle = '#1c1c1c'; ctx4.fillRect(0, 0, texSize, texSize);
ctx4.fillStyle = '#888'; ctx4.fillRect(16, 0, 32, texSize);
ctx4.strokeStyle = '#444'; strokeRect = (16, 0, 32, texSize);
ctx4.strokeRect(16, 0, 32, texSize);
textures[4] = c4;
}
// Draw procedural Cacodemon sprite
function drawCacodemon(ctx, size) {
ctx.fillStyle = '#cc1111'; // red ball body
ctx.beginPath(); ctx.arc(size/2, size/2, size*0.4, 0, Math.PI*2); ctx.fill();
// Horns
ctx.fillStyle = '#990000';
ctx.beginPath(); ctx.moveTo(size*0.2, size*0.2); ctx.lineTo(size*0.1, size*0.05); ctx.lineTo(size*0.35, size*0.2); ctx.fill();
ctx.beginPath(); ctx.moveTo(size*0.8, size*0.2); ctx.lineTo(size*0.9, size*0.05); ctx.lineTo(size*0.65, size*0.2); ctx.fill();
// Eye
ctx.fillStyle = '#00ffff';
ctx.beginPath(); ctx.arc(size/2, size*0.4, size*0.08, 0, Math.PI*2); ctx.fill();
// Mouth
ctx.fillStyle = '#111';
ctx.fillRect(size*0.3, size*0.6, size*0.4, size*0.15);
ctx.fillStyle = '#fff'; // teeth
ctx.fillRect(size*0.35, size*0.6, size*0.05, size*0.05);
ctx.fillRect(size*0.6, size*0.6, size*0.05, size*0.05);
}
// Draw procedural Imp sprite
function drawImp(ctx, size) {
ctx.fillStyle = '#5c4033'; // brown body
ctx.beginPath(); ctx.ellipse(size/2, size/2, size*0.25, size*0.4, 0, 0, Math.PI*2); ctx.fill();
// Head
ctx.beginPath(); ctx.arc(size/2, size*0.25, size*0.18, 0, Math.PI*2); ctx.fill();
// Red glowing eyes
ctx.fillStyle = '#ff0000';
ctx.fillRect(size*0.42, size*0.22, size*0.04, size*0.04);
ctx.fillRect(size*0.54, size*0.22, size*0.04, size*0.04);
// Spikes/Horns
ctx.fillStyle = '#9c7a5c';
ctx.fillRect(size*0.48, size*0.05, size*0.04, size*0.08);
}
// Helper to pre-render sprite assets
const spriteCanvases = {};
function generateSpriteCanvases() {
const size = 64;
['cacodemon', 'imp', 'medkit', 'ammo', 'dead_imp', 'dead_cacodemon'].forEach(name => {
const c = document.createElement('canvas'); c.width = size; c.height = size;
const sCtx = c.getContext('2d');
if (name === 'cacodemon') drawCacodemon(sCtx, size);
else if (name === 'imp') drawImp(sCtx, size);
else if (name === 'dead_cacodemon') {
sCtx.fillStyle = '#aa0000'; sCtx.beginPath(); sCtx.ellipse(size/2, size*0.7, size*0.4, size*0.15, 0, 0, Math.PI*2); sCtx.fill();
sCtx.fillStyle = '#660000'; sCtx.fillRect(size*0.4, size*0.6, size*0.2, size*0.1);
}
else if (name === 'dead_imp') {
sCtx.fillStyle = '#5c2211'; sCtx.beginPath(); sCtx.ellipse(size/2, size*0.7, size*0.35, size*0.12, 0, 0, Math.PI*2); sCtx.fill();
}
else if (name === 'medkit') {
sCtx.fillStyle = '#fff'; sCtx.fillRect(size*0.25, size*0.4, size*0.5, size*0.4);
sCtx.fillStyle = '#ff0000';
sCtx.fillRect(size*0.45, size*0.45, size*0.1, size*0.3);
sCtx.fillRect(size*0.35, size*0.55, size*0.3, size*0.1);
}
else if (name === 'ammo') {
sCtx.fillStyle = '#ccaa00'; sCtx.fillRect(size*0.3, size*0.45, size*0.4, size*0.3);
sCtx.fillStyle = '#555'; sCtx.fillRect(size*0.35, size*0.45, size*0.1, size*0.3);
sCtx.fillRect(size*0.55, size*0.45, size*0.1, size*0.3);
}
spriteCanvases[name] = c;
});
}
// Draw HUD face
const hudFaceCanvas = document.getElementById('hudFace');
const hfCtx = hudFaceCanvas.getContext('2d');
function updateHudFace() {
const w = hudFaceCanvas.width;
const h = hudFaceCanvas.height;
hfCtx.fillStyle = '#dca380'; hfCtx.fillRect(0,0,w,h); // skin tone
// Hair
hfCtx.fillStyle = '#442200'; hfCtx.fillRect(0, 0, w, 10);
// Eyes looking left/right depending on game frame
hfCtx.fillStyle = '#fff';
hfCtx.fillRect(8, 14, 6, 4);
hfCtx.fillRect(26, 14, 6, 4);
const lookOffset = Math.sin(Date.now() / 1500) > 0 ? 1 : -1;
hfCtx.fillStyle = '#0000ff'; // pupils
hfCtx.fillRect(9 + lookOffset, 15, 3, 3);
hfCtx.fillRect(27 + lookOffset, 15, 3, 3);
// Mouth / Nose
hfCtx.fillStyle = '#a05030';
hfCtx.fillRect(18, 20, 4, 6); // nose
// Bloody overlay if health is low
if (health < 40) {
hfCtx.fillStyle = 'rgba(200, 0, 0, 0.5)';
hfCtx.fillRect(0, 0, w, h);
}
}
// Keyboard Event Handlers
window.addEventListener('keydown', e => {
const k = e.key.toLowerCase();
if (k === ' ') {
fireWeapon();
if (gameStarted) e.preventDefault();
}
if (k === 'q' || k === '1' || k === '2') {
swapWeapon();
if (gameStarted) e.preventDefault();
}
if (k in keys) {
keys[k] = true;
if (gameStarted) e.preventDefault();
}
});
window.addEventListener('keyup', e => {
const k = e.key.toLowerCase();
if (k in keys) {
keys[k] = false;
if (gameStarted) e.preventDefault();
}
});
// Touch Controller Listeners
const hookTouch = (id, actionDown, actionUp) => {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener('mousedown', e => { e.preventDefault(); actionDown(); });
el.addEventListener('mouseup', e => { e.preventDefault(); actionUp(); });
el.addEventListener('touchstart', e => { e.preventDefault(); actionDown(); }, { passive: false });
el.addEventListener('touchend', e => { e.preventDefault(); actionUp(); }, { passive: false });
};
hookTouch('btn-up', () => { keys.w = true; }, () => { keys.w = false; });
hookTouch('btn-down', () => { keys.s = true; }, () => { keys.s = false; });
hookTouch('btn-left', () => { keys.arrowleft = true; }, () => { keys.arrowleft = false; });
hookTouch('btn-right', () => { keys.arrowright = true; }, () => { keys.arrowright = false; });
document.getElementById('btn-use').onclick = () => { triggerInteraction(); };
document.getElementById('btn-fire').onclick = () => { fireWeapon(); };
document.getElementById('btn-weapon').onclick = () => { swapWeapon(); };
// Setup start overlay click
document.getElementById('start-btn').onclick = startGame;
document.getElementById('restart-btn').onclick = retryGame;
function startGame() {
sfx.init();
document.getElementById('menu').classList.add('hidden');
loadSector(1);
gameStarted = true;
gameLoop();
}
function retryGame() {
health = 100;
armor = 50;
ammo = 50;
isGameOver = false;
loadSector(1);
document.getElementById('gameover').classList.add('hidden');
}
function swapWeapon() {
currentWeapon = currentWeapon === 'PISTOL' ? 'SHOTGUN' : 'PISTOL';
document.getElementById('hud-weapon').textContent = currentWeapon;
sfx.playItem();
}
function triggerFlash() {
const f = document.getElementById('flash');
f.style.backgroundColor = 'rgba(255, 0, 0, 0.4)';
setTimeout(() => {
f.style.backgroundColor = 'rgba(255, 0, 0, 0)';
}, 80);
}
function fireWeapon() {
if (!gameStarted || isGameOver || isShooting) return;
if (ammo <= 0) {
sfx.playPain(); // Out of ammo click/grunt
return;
}
isShooting = true;
weaponAnimFrame = 1;
sfx.playShoot();
// Damage checking on active enemies near central view axis
let hitSomething = false;
sprites.forEach(s => {
if (s.type === 'enemy' && !s.dead) {
// Calculate vector from player to sprite
const sX = s.x - posX;
const sY = s.y - posY;
// Check alignment with direction vector
const angle = Math.atan2(sY, sX);
const playerAngle = Math.atan2(dirY, dirX);
let diff = angle - playerAngle;
while (diff < -Math.PI) diff += Math.PI * 2;
while (diff > Math.PI) diff -= Math.PI * 2;
const dist = Math.sqrt(sX*sX + sY*sY);
if (Math.abs(diff) < 0.25 && dist < 12) {
const dmg = currentWeapon === 'SHOTGUN' ? 25 : 12;
s.health -= dmg;
sfx.playExplosion();
hitSomething = true;
if (s.health <= 0) {
s.dead = true;
s.texture = s.texture === 'cacodemon' ? 'dead_cacodemon' : 'dead_imp';
}
}
}
});
ammo -= currentWeapon === 'SHOTGUN' ? 2 : 1;
if (ammo < 0) ammo = 0;
updateHUD();
}
function triggerInteraction() {
// Check cell directly ahead of player
const checkX = Math.floor(posX + dirX * 1.5);
const checkY = Math.floor(posY + dirY * 1.5);
if (worldMap[checkY] && worldMap[checkY][checkX] === 3) {
// Trigger independent door open
if (!openDoors.some(d => d.x === checkX && d.y === checkY)) {
worldMap[checkY][checkX] = 0; // set passable
openDoors.push({ x: checkX, y: checkY, timer: 180 });
sfx.playDoor();
}
}
}
// Update HUD
function updateHUD() {
document.getElementById('hud-health').textContent = `${health}%`;
document.getElementById('hud-armor').textContent = `${armor}%`;
document.getElementById('hud-ammo').textContent = ammo;
}
// Basic Enemy AI & Item pickups
function updateGameObjects() {
if (keys.e) {
triggerInteraction();
keys.e = false;
}
// Process open doors independently
for (let i = openDoors.length - 1; i >= 0; i--) {
const door = openDoors[i];
door.timer--;
if (door.timer <= 0) {
// Close door if player is not standing in it
if (Math.floor(posX) !== door.x || Math.floor(posY) !== door.y) {
worldMap[door.y][door.x] = 3;
sfx.playDoor();
openDoors.splice(i, 1);
} else {
door.timer = 60; // wait 1 second and check again
}
}
}
sprites.forEach(s => {
// Pickups
if (s.type === 'item') {
const dist = Math.sqrt((s.x - posX)**2 + (s.y - posY)**2);
if (dist < 0.5) {
if (s.texture === 'medkit' && health < 100) {
health = Math.min(100, health + 25);
sfx.playItem();
sprites = sprites.filter(item => item !== s);
updateHUD();
} else if (s.texture === 'ammo' && ammo < 100) {
ammo = Math.min(100, ammo + 30);
sfx.playItem();
sprites = sprites.filter(item => item !== s);
updateHUD();
}
}
}
// Enemy AI
if (s.type === 'enemy' && !s.dead) {
const dist = Math.sqrt((s.x - posX)**2 + (s.y - posY)**2);
if (dist < 10 && dist > 1.2) {
// Move towards player
const dx = (posX - s.x) / dist;
const dy = (posY - s.y) / dist;
const newX = s.x + dx * s.speed;
const newY = s.y + dy * s.speed;
if (worldMap[Math.floor(s.y)][Math.floor(newX)] === 0) s.x = newX;
if (worldMap[Math.floor(newY)][Math.floor(s.x)] === 0) s.y = newY;
} else if (dist <= 1.2) {
// Attack player
if (Math.random() < 0.05) {
triggerFlash();
sfx.playPain();
if (armor > 0) {
armor = Math.max(0, armor - 5);
health = Math.max(0, health - 5);
} else {
health = Math.max(0, health - 10);
}
updateHUD();
if (health <= 0) {
isGameOver = true;
document.getElementById('gameover-title').textContent = "YOU DIED";
document.getElementById('gameover').classList.remove('hidden');
}
}
}
}
});
// Check Sector Victory & Progression
const aliveEnemies = sprites.filter(s => s.type === 'enemy' && !s.dead).length;
if (aliveEnemies === 0 && !isGameOver) {
if (currentSector === 1) {
loadSector(2);
sfx.playItem();
} else {
isGameOver = true;
document.getElementById('gameover-title').textContent = "VICTORY!";
document.getElementById('gameover-subtitle').textContent = "All Sectors cleared!";
document.getElementById('gameover').classList.remove('hidden');
}
}
}
// Main Raycast Render Frame
function renderFrame() {
// 1. Clear background (Ceiling & Floor)
ctx.fillStyle = '#1e1e1e'; // Ceiling
ctx.fillRect(0, 0, viewWidth, viewHeight/2);
ctx.fillStyle = '#2d251e'; // Floor
ctx.fillRect(0, viewHeight/2, viewWidth, viewHeight/2);
// Z-buffer for sprite rendering
const zBuffer = [];
// 2. Wall Casting
for (let x = 0; x < viewWidth; x++) {
const cameraX = 2 * x / viewWidth - 1;
const rayDirX = dirX + planeX * cameraX;
const rayDirY = dirY + planeY * cameraX;
let mapX = Math.floor(posX);
let mapY = Math.floor(posY);
let sideDistX, sideDistY;
const deltaDistX = (rayDirX === 0) ? 1e30 : Math.abs(1 / rayDirX);
const deltaDistY = (rayDirY === 0) ? 1e30 : Math.abs(1 / rayDirY);
let perpWallDist;
let stepX, stepY;
let hit = 0;
let side;
if (rayDirX < 0) {
stepX = -1;
sideDistX = (posX - mapX) * deltaDistX;
} else {
stepX = 1;
sideDistX = (mapX + 1.0 - posX) * deltaDistX;
}
if (rayDirY < 0) {
stepY = -1;
sideDistY = (posY - mapY) * deltaDistY;
} else {
stepY = 1;
sideDistY = (mapY + 1.0 - posY) * deltaDistY;
}
// DDA
while (hit === 0) {
if (sideDistX < sideDistY) {
sideDistX += deltaDistX;
mapX += stepX;
side = 0;
} else {
sideDistY += deltaDistY;
mapY += stepY;
side = 1;
}
if (worldMap[mapY] && worldMap[mapY][mapX] > 0) hit = 1;
}
if (side === 0) perpWallDist = (sideDistX - deltaDistX);
else perpWallDist = (sideDistY - deltaDistY);
zBuffer[x] = perpWallDist;
const lineHeight = Math.floor(viewHeight / perpWallDist);
let drawStart = -lineHeight / 2 + viewHeight / 2;
if (drawStart < 0) drawStart = 0;
let drawEnd = lineHeight / 2 + viewHeight / 2;
if (drawEnd >= viewHeight) drawEnd = viewHeight - 1;
// Draw Wall Column Texture
const texNum = worldMap[mapY] ? worldMap[mapY][mapX] : 1;
const tex = textures[texNum] || textures[1];
let wallX;
if (side === 0) wallX = posY + perpWallDist * rayDirY;
else wallX = posX + perpWallDist * rayDirX;
wallX -= Math.floor(wallX);
let texX = Math.floor(wallX * texSize);
if (side === 0 && rayDirX > 0) texX = texSize - texX - 1;
if (side === 1 && rayDirY < 0) texX = texSize - texX - 1;
ctx.drawImage(tex, texX, 0, 1, texSize, x, drawStart, 1, drawEnd - drawStart);
// Simple shade for depth
if (side === 1) {
ctx.fillStyle = 'rgba(0, 0, 0, 0.4)';
ctx.fillRect(x, drawStart, 1, drawEnd - drawStart);
}
}
// 3. Sprite Casting (Enemies & Items)
const spriteOrder = sprites.map((s, i) => ({ index: i, dist: ((posX - s.x)**2 + (posY - s.y)**2) }));
spriteOrder.sort((a,b) => b.dist - a.dist);
spriteOrder.forEach(item => {
const s = sprites[item.index];
const spriteX = s.x - posX;
const spriteY = s.y - posY;
const invDet = 1.0 / (planeX * dirY - dirX * planeY);
const transformX = invDet * (dirY * spriteX - dirX * spriteY);
const transformY = invDet * (-planeY * spriteX + planeX * spriteY); // depth in front screen
if (transformY > 0) {
const spriteScreenX = Math.floor((viewWidth / 2) * (1 + transformX / transformY));
const spriteHeight = Math.abs(Math.floor(viewHeight / transformY));
let drawStartY = -spriteHeight / 2 + viewHeight / 2;
if (drawStartY < 0) drawStartY = 0;
let drawEndY = spriteHeight / 2 + viewHeight / 2;
if (drawEndY >= viewHeight) drawEndY = viewHeight - 1;
const spriteWidth = Math.abs(Math.floor(viewHeight / transformY));
let drawStartX = -spriteWidth / 2 + spriteScreenX;
if (drawStartX < 0) drawStartX = 0;
let drawEndX = spriteWidth / 2 + spriteScreenX;
if (drawEndX >= viewWidth) drawEndX = viewWidth - 1;
const sTex = spriteCanvases[s.texture];
if (sTex) {
for (let stripe = drawStartX; stripe < drawEndX; stripe++) {
const texX = Math.floor(256 * (stripe - (-spriteWidth / 2 + spriteScreenX)) * texSize / spriteWidth) / 256;
if (transformY < zBuffer[stripe]) {
ctx.drawImage(sTex, texX, 0, 1, texSize, stripe, drawStartY, 1, drawEndY - drawStartY);
}
}
}
}
});
// 4. Render Weapon HUD Overlay
const weaponYOffset = isShooting ? Math.sin(weaponAnimFrame * 0.5) * 15 : 0;
const weaponBob = Math.sin(Date.now() / 150) * 4 * (keys.w || keys.s ? 1 : 0);
ctx.save();
ctx.translate(viewWidth / 2 + weaponBob, viewHeight - 20 + weaponYOffset);
ctx.fillStyle = '#444'; // Gun barrel shape
if (currentWeapon === 'PISTOL') {
ctx.fillRect(-10, -50, 20, 50);
ctx.fillStyle = '#222';
ctx.fillRect(-6, -45, 12, 12);
if (isShooting && weaponAnimFrame < 5) {
// Flash muzzle
ctx.fillStyle = '#ffcc00';
ctx.beginPath(); ctx.arc(0, -60, 12, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.beginPath(); ctx.arc(0, -60, 6, 0, Math.PI * 2); ctx.fill();
}
} else {
// Shotgun
ctx.fillRect(-16, -60, 14, 60);
ctx.fillRect(2, -60, 14, 60);
if (isShooting && weaponAnimFrame < 5) {
ctx.fillStyle = '#ff3300';
ctx.beginPath(); ctx.arc(-8, -75, 16, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(8, -75, 16, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#ffcc00';
ctx.beginPath(); ctx.arc(-8, -75, 8, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(8, -75, 8, 0, Math.PI * 2); ctx.fill();
}
}
ctx.restore();
if (isShooting) {
weaponAnimFrame++;
if (weaponAnimFrame > 8) {
isShooting = false;
weaponAnimFrame = 0;
}
}
// Update Face HUD
updateHudFace();
}
// Game input update & loop
function updatePlayerInput() {
if (isGameOver) return;
let moveX = 0;
let moveY = 0;
if (keys.w || keys.arrowup) {
moveX += dirX * moveSpeed;
moveY += dirY * moveSpeed;
}
if (keys.s || keys.arrowdown) {
moveX -= dirX * moveSpeed;
moveY -= dirY * moveSpeed;
}
// Smooth Sliding Collision Detection with wall padding
const buffer = 0.2;
if (moveX !== 0) {
const signX = moveX > 0 ? buffer : -buffer;
const checkX = posX + moveX + signX;
if (worldMap[Math.floor(posY)] && worldMap[Math.floor(posY)][Math.floor(checkX)] === 0) {
posX += moveX;
}
}
if (moveY !== 0) {
const signY = moveY > 0 ? buffer : -buffer;
const checkY = posY + moveY + signY;
if (worldMap[Math.floor(checkY)] && worldMap[Math.floor(checkY)][Math.floor(posX)] === 0) {
posY += moveY;
}
}
// Rotation
if (keys.a || keys.arrowleft) {
const oldDirX = dirX;
dirX = dirX * Math.cos(-rotSpeed) - dirY * Math.sin(-rotSpeed);
dirY = oldDirX * Math.sin(-rotSpeed) + dirY * Math.cos(-rotSpeed);
const oldPlaneX = planeX;
planeX = planeX * Math.cos(-rotSpeed) - planeY * Math.sin(-rotSpeed);
planeY = oldPlaneX * Math.sin(-rotSpeed) + planeY * Math.cos(-rotSpeed);
}
if (keys.d || keys.arrowright) {
const oldDirX = dirX;
dirX = dirX * Math.cos(rotSpeed) - dirY * Math.sin(rotSpeed);
dirY = oldDirX * Math.sin(rotSpeed) + dirY * Math.cos(rotSpeed);
const oldPlaneX = planeX;
planeX = planeX * Math.cos(rotSpeed) - planeY * Math.sin(rotSpeed);
planeY = oldPlaneX * Math.sin(rotSpeed) + planeY * Math.cos(rotSpeed);
}
}
function gameLoop() {
if (!gameStarted) return;
updatePlayerInput();
updateGameObjects();
renderFrame();
requestAnimationFrame(gameLoop);
}
// Pre-generate assets
generateTextures();
generateSpriteCanvases();
</script>
Game Features & Controls
- Procedural 3D Graphics: Rendered via premium widescreen raycasting with full retro styling.
- Weapon System: Cycle weapons using the SWAP button, numeric keys, or Q, featuring animated gunplay and procedural hit impact checks.
- Synthesized Retro SFX: Native Audio Synth powers the authentic 8-bit sound effects.
- Multi-Sector Progression: Complete all objectives and eliminate all enemies to transition automatically to Sector 2.
- Dungeon Grid: Level consists of interactive sliding blue security doors, pillars, item caches, and active monsters.
- Responsive Controls: Desktop players can utilize complete keyboard layouts, while mobile devices leverage custom virtual controls.