preview
WASD • Q E (turn) • Space (shoot)
🔥 slow (GPU melter) 📱 RIP mobile users
source
<!DOCTYPE html>
<!-- theres a freeze when finishing level for some reason (generating new level?) -->
<html>
<head>
    <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
    <title>Wolfenstein 3D: Dithered Fog</title>
    <style>
        body { background: #111; color: #ddd; font-family: 'Courier New', monospace; overflow: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; margin: 0; }
        #screen { border: 4px solid #333; image-rendering: pixelated; background: #000; width: 800px; height: 600px; cursor: none; }
        #hud { display: flex; justify-content: space-between; width: 800px; padding: 12px; background: #222; font-size: 20px; font-weight: bold; border: 2px solid #333; border-top: 0; box-sizing: border-box;}
        .red { color: #ff5555; } .green { color: #55ff55; } .gold { color: gold; }
        #msg { position: absolute; top: 40%; font-size: 60px; text-shadow: 4px 4px #000; display: none; pointer-events: none; z-index: 10; font-family: impact, sans-serif; text-transform: uppercase; color: #fff;}
    </style>
</head>
<body>

    <canvas id="screen" width="320" height="240"></canvas>
    <div id="hud">
        <span class="green">HP: <span id="health-disp">100</span>%</span>
        <span class="gold">FLOOR <span id="level-disp">1</span></span>
    </div>
    <div id="msg"></div>

<script>
    // --- CONSTANTS ---
    const SCREEN_W = 320;
    const SCREEN_H = 240;
    const TEX_SIZE = 64; 
    const TICK = 1000/30;
    
    // --- FOG CONFIGURATION ---
    // 4x4 Ordered Dither Matrix
    const BAYER = [
         0,  8,  2, 10,
        12,  4, 14,  6,
         3, 11,  1,  9,
        15,  7, 13,  5
    ];
    // Start fogging at 1.5 blocks away, become solid black at ~7 blocks
    const FOG_START = 7.0;
    const FOG_DENSITY = 2.8;

    // --- GLOBAL STATE ---
    const canvas = document.getElementById('screen');
    const ctx = canvas.getContext('2d', { alpha: false }); 
    const imgData = ctx.createImageData(SCREEN_W, SCREEN_H);
    const buf = new Uint32Array(imgData.data.buffer);
    
    let keys = {};
    let map = [];
    let mapW = 32;
    let mapH = 32;
    let levelNum = 1;
    let sprites = [];
    let projectiles = [];
    let zBuffer = new Array(SCREEN_W).fill(0);
    
    // Textures
    let texWall, texFloor, texCeil;
    
    let player = {
        x: 0, y: 0,
        dirX: -1, dirY: 0,
        planeX: 0, planeY: 0.66,
        speed: 0.12, rotSpeed: 0.06,
        health: 100
    };
    
    let gameState = "PLAY";
    let flashTimer = 0;
    
    // --- ASSET GENERATION ---
    const toCol = (r, g, b) => (255 << 24) | (b << 16) | (g << 8) | r;
    
    function genTexture(type) {
        let data = new Uint32Array(TEX_SIZE * TEX_SIZE);
        for(let y=0; y<TEX_SIZE; y++) {
            for(let x=0; x<TEX_SIZE; x++) {
                let c; 
                if(type === 0) { // Bricks
                    let isMortar = (y%16 < 2) || ((Math.floor(y/16)%2===0 ? x : x+16) % 32 < 2);
                    let noise = Math.random() * 20;
                    c = isMortar ? toCol(50,50,50) : toCol(140+noise, 60+noise, 60+noise); 
                } else if (type === 1) { // Floor
                    let tile = ((x/16)|0 + (y/16)|0) % 2;
                    let noise = Math.random() * 10;
                    let b = tile ? 80 : 100;
                    c = toCol(b+noise, b+noise, b+noise);
                } else { // Ceiling
                    let grain = (x%4) * 5 + Math.random()*10;
                    c = toCol(60+grain, 40+grain, 20);
                }
                data[y*TEX_SIZE + x] = c;
            }
        }
        return data;
    }
    
    // --- LEVEL GENERATION ---
    function generateLevel(level) {
        let size = 30 + level * 4;
        mapW = size; mapH = size;
        map = [];
        for(let y=0; y<size; y++) map.push(new Array(size).fill(1));
    
        let floorTiles = [];
        let walkers = [{x: (size/2)|0, y: (size/2)|0}];
        let maxFloors = Math.floor(size*size * 0.35);
        
        while(floorTiles.length < maxFloors && walkers.length > 0) {
            for(let i = walkers.length-1; i>=0; i--) {
                let w = walkers[i];
                if(w.x>1 && w.x<size-2 && w.y>1 && w.y<size-2) {
                    if(map[w.y][w.x] === 1) {
                        map[w.y][w.x] = 0;
                        floorTiles.push({x: w.x, y: w.y});
                    }
                }
                let dir = Math.floor(Math.random() * 4);
                if(dir===0) w.y--; else if(dir===1) w.y++;
                else if(dir===2) w.x--; else w.x++;
    
                if(Math.random() < 0.05 && walkers.length < 5) walkers.push({x: w.x, y: w.y});
                if(Math.random() < 0.02 && walkers.length > 1) walkers.splice(i, 1);
            }
        }
    
        sprites = [];
        player.x = floorTiles[0].x + 0.5;
        player.y = floorTiles[0].y + 0.5;
        player.dirX = -1; player.dirY = 0; player.planeX = 0; player.planeY = 0.66;
    
        let exitTile = floorTiles[floorTiles.length-1];
        sprites.push({x: exitTile.x+0.5, y: exitTile.y+0.5, type: 4, active: true});
    
        for(let i=1; i<floorTiles.length-1; i++) {
            let t = floorTiles[i];
            if(Math.random() < 0.05 + (level*0.01)) {
                    if(Math.sqrt((t.x-player.x)**2 + (t.y-player.y)**2) > 5)
                    sprites.push({x: t.x+0.5, y: t.y+0.5, type: 2, active: true, state: 0, timer: 0});
            } else if (Math.random() < 0.02) {
                sprites.push({x: t.x+0.5, y: t.y+0.5, type: 3, active: true});
            }
        }
        document.getElementById('level-disp').innerText = level;
        document.getElementById('msg').style.display = "none";
    }
    
    function init() {
        texWall = genTexture(0);
        texFloor = genTexture(1);
        texCeil = genTexture(2);
        generateLevel(levelNum);
        
        window.addEventListener('keydown', e => keys[e.code] = true);
        window.addEventListener('keyup', e => keys[e.code] = false);
        
        setInterval(gameLoop, TICK);
    }
    
    // --- LOGIC ---
    function hasLineOfSight(x1, y1, x2, y2) {
        let dist = Math.sqrt((x2-x1)**2 + (y2-y1)**2);
        let steps = Math.ceil(dist * 2);
        let dx = (x2-x1)/steps, dy = (y2-y1)/steps;
        let cx = x1, cy = y1;
        for(let i=0; i<steps; i++) {
            cx += dx; cy += dy;
            if(map[Math.floor(cy)][Math.floor(cx)] === 1) return false;
        }
        return true;
    }
    
    function nextLevel() {
        levelNum++;
        gameState = "TRANSITION";
        let msg = document.getElementById('msg');
        msg.innerHTML = "<span class='gold'>FLOOR CLEARED</span>";
        msg.style.display = 'block';
        msg.style.left = "200px";
        setTimeout(() => { generateLevel(levelNum); gameState = "PLAY"; }, 2000);
    }
    
    function update() {
        if(gameState !== "PLAY") return;
    
        let rot = 0;
        if (keys['ArrowRight'] || keys['KeyE']) rot = -player.rotSpeed;
        if (keys['ArrowLeft'] || keys['KeyQ']) rot = player.rotSpeed;
        if (rot) {
            let oldDirX = player.dirX;
            player.dirX = player.dirX * Math.cos(rot) - player.dirY * Math.sin(rot);
            player.dirY = oldDirX * Math.sin(rot) + player.dirY * Math.cos(rot);
            let oldPlaneX = player.planeX;
            player.planeX = player.planeX * Math.cos(rot) - player.planeY * Math.sin(rot);
            player.planeY = oldPlaneX * Math.sin(rot) + player.planeY * Math.cos(rot);
        }
    
        let moveX = 0, moveY = 0;
        if (keys['KeyW'] || keys['ArrowUp']) { moveX += player.dirX; moveY += player.dirY; }
        if (keys['KeyS'] || keys['ArrowDown']) { moveX -= player.dirX; moveY -= player.dirY; }
        if (keys['KeyD']) { moveX += player.dirY; moveY -= player.dirX; }
        if (keys['KeyA']) { moveX -= player.dirY; moveY += player.dirX; }
    
        if (moveX || moveY) {
            let len = Math.sqrt(moveX*moveX + moveY*moveY);
            moveX = (moveX/len)*player.speed; moveY = (moveY/len)*player.speed;
            if(map[Math.floor(player.y)][Math.floor(player.x+moveX*3)] === 0) player.x += moveX;
            if(map[Math.floor(player.y+moveY*3)][Math.floor(player.x)] === 0) player.y += moveY;
        }
    
        if (keys['Space']) {
            keys['Space'] = false; flashTimer = 4;
            sprites.forEach(s => {
                if(s.type === 2 && s.active) {
                    let dx = s.x - player.x, dy = s.y - player.y;
                    let spriteDir = Math.atan2(dy, dx) - Math.atan2(player.dirY, player.dirX);
                    while(spriteDir < -Math.PI) spriteDir += 2*Math.PI;
                    while(spriteDir > Math.PI) spriteDir -= 2*Math.PI;
                    if (Math.abs(spriteDir) < 0.25 && Math.sqrt(dx*dx+dy*dy) < 8 && hasLineOfSight(player.x, player.y, s.x, s.y)) {
                        s.active = false;
                    }
                }
            });
        }
    
        for(let i = projectiles.length - 1; i >= 0; i--) {
            let p = projectiles[i];
            p.x += p.dx; p.y += p.dy;
            if(map[Math.floor(p.y)][Math.floor(p.x)] === 1) { projectiles.splice(i, 1); continue; }
            if(Math.sqrt((p.x-player.x)**2 + (p.y-player.y)**2) < 0.3) {
                player.health -= 10;
                projectiles.splice(i, 1);
                for(let j=0; j<buf.length; j+=4) buf[j] |= 0xFF0000FF; 
            }
        }
    
        sprites.forEach(s => {
            if(!s.active) return;
            let dx = player.x - s.x, dy = player.y - s.y, dist = Math.sqrt(dx*dx + dy*dy);
            if (dist < 0.6) {
                if(s.type === 3) { player.health = Math.min(100, player.health + 25); s.active = false; }
                if(s.type === 4) nextLevel();
            }
            if(s.type === 2) {
                if (dist < 12 && hasLineOfSight(s.x, s.y, player.x, player.y)) {
                    if(s.state === 0) { s.state = 1; s.timer = 15; }
                    if(s.state === 1) {
                        if(dist > 3) { s.x += (dx/dist)*0.04; s.y += (dy/dist)*0.04; }
                        if(s.timer > 0) s.timer--; 
                        else {
                            projectiles.push({ x: s.x, y: s.y, dx: (dx/dist)*0.25, dy: (dy/dist)*0.25 });
                            s.timer = 40;
                        }
                    }
                } else s.state = 0;
            }
        });
        
        document.getElementById('health-disp').innerText = Math.floor(player.health);
        if(player.health <= 0) {
            gameState = "LOSE";
            let msg = document.getElementById('msg');
            msg.innerHTML = "<span class='red'>YOU DIED</span><br><span style='font-size:30px'>REFRESH TO RESTART</span>";
            msg.style.display = 'block';
            msg.style.left = "280px";
        }
    }
    
    // --- RENDERER ---
    function draw() {
        buf.fill(0); 
    
        // 1. FLOOR & CEILING CASTING WITH FOG
        for(let y = 0; y < SCREEN_H; y++) {
            let isFloor = y > SCREEN_H / 2;
            let rayDirX0 = player.dirX - player.planeX;
            let rayDirY0 = player.dirY - player.planeY;
            let rayDirX1 = player.dirX + player.planeX;
            let rayDirY1 = player.dirY + player.planeY;
    
            let p = isFloor ? (y - SCREEN_H / 2) : (SCREEN_H / 2 - y);
            let posZ = 0.5 * SCREEN_H;
            let rowDist = posZ / p;
    
            let floorStepX = rowDist * (rayDirX1 - rayDirX0) / SCREEN_W;
            let floorStepY = rowDist * (rayDirY1 - rayDirY0) / SCREEN_W;
            let floorX = player.x + rowDist * rayDirX0;
            let floorY = player.y + rowDist * rayDirY0;
            
            // Calculate Base Fog Value for this row
            // (dist - start) * density. 
            let fogVal = (rowDist - FOG_START) * FOG_DENSITY;

            for(let x = 0; x < SCREEN_W; x++) {
                
                // BAYER DITHER CHECK
                // Get bayer value (0-15) based on screen coordinates modulo 4
                // ((y & 3) << 2) + (x & 3) is optimized (y%4)*4 + (x%4)
                let bayerThreshold = BAYER[((y & 3) << 2) + (x & 3)];
                
                if (fogVal > bayerThreshold) {
                    // Render Black (Full Opacity)
                    buf[y * SCREEN_W + x] = 0xFF000000;
                } else {
                    let tx = Math.floor(TEX_SIZE * (floorX - Math.floor(floorX))) & (TEX_SIZE - 1);
                    let ty = Math.floor(TEX_SIZE * (floorY - Math.floor(floorY))) & (TEX_SIZE - 1);
                    
                    let color = isFloor ? texFloor[TEX_SIZE * ty + tx] : texCeil[TEX_SIZE * ty + tx];
                    buf[y * SCREEN_W + x] = color;
                }

                floorX += floorStepX;
                floorY += floorStepY;
            }
        }
    
        // 2. WALL CASTING WITH FOG
        for(let x = 0; x < SCREEN_W; x++) {
            let cameraX = 2 * x / SCREEN_W - 1;
            let rayDirX = player.dirX + player.planeX * cameraX;
            let rayDirY = player.dirY + player.planeY * cameraX;
    
            let mapX = Math.floor(player.x), mapY = Math.floor(player.y);
            let sideDistX, sideDistY, deltaDistX = Math.abs(1/rayDirX), deltaDistY = Math.abs(1/rayDirY);
            let stepX, stepY, hit = 0, side;
    
            if (rayDirX < 0) { stepX = -1; sideDistX = (player.x - mapX) * deltaDistX; }
            else { stepX = 1; sideDistX = (mapX + 1.0 - player.x) * deltaDistX; }
            if (rayDirY < 0) { stepY = -1; sideDistY = (player.y - mapY) * deltaDistY; }
            else { stepY = 1; sideDistY = (mapY + 1.0 - player.y) * deltaDistY; }
    
            while (hit === 0) {
                if (sideDistX < sideDistY) { sideDistX += deltaDistX; mapX += stepX; side = 0; }
                else { sideDistY += deltaDistY; mapY += stepY; side = 1; }
                if (map[mapY][mapX] > 0) hit = 1;
            }
    
            let perpWallDist = (side === 0) ? (sideDistX - deltaDistX) : (sideDistY - deltaDistY);
            zBuffer[x] = perpWallDist;
    
            let lineHeight = Math.floor(SCREEN_H / perpWallDist);
            let drawStart = -lineHeight / 2 + SCREEN_H / 2;
            if(drawStart < 0) drawStart = 0;
            let drawEnd = lineHeight / 2 + SCREEN_H / 2;
            if(drawEnd >= SCREEN_H) drawEnd = SCREEN_H - 1;
    
            let wallX = (side == 0) ? player.y + perpWallDist * rayDirY : player.x + perpWallDist * rayDirX;
            wallX -= Math.floor(wallX);
            let texX = Math.floor(wallX * TEX_SIZE) & (TEX_SIZE - 1);
            if(side == 0 && rayDirX > 0) texX = TEX_SIZE - texX - 1;
            if(side == 1 && rayDirY < 0) texX = TEX_SIZE - texX - 1;
    
            let step = 1.0 * TEX_SIZE / lineHeight;
            let texPos = (drawStart - SCREEN_H / 2 + lineHeight / 2) * step;
            
            // Calculate Fog Value for this wall strip
            let fogVal = (perpWallDist - FOG_START) * FOG_DENSITY;

            for(let y = Math.floor(drawStart); y < Math.floor(drawEnd); y++) {
                
                // BAYER DITHER CHECK
                let bayerThreshold = BAYER[((y & 3) << 2) + (x & 3)];
                
                if (fogVal > bayerThreshold) {
                    buf[y * SCREEN_W + x] = 0xFF000000;
                } else {
                    let texY = Math.floor(texPos) & (TEX_SIZE - 1);
                    texPos += step;
                    let color = texWall[TEX_SIZE * texY + texX];
                    
                    if(side === 1) {
                        color = ((color >>> 1) & 0x7F7F7F7F) | 0xFF000000;
                    }
                    buf[y * SCREEN_W + x] = color;
                }
            }
        }
    
        ctx.putImageData(imgData, 0, 0);
    
        // 3. SPRITES
        // Sprites use Canvas API, so we simulate fog with CSS brightness filter
        // because manual pixel rasterization for sprites is too slow/complex here.
        let renderList = sprites.filter(s => s.active).map(s => ({...s, isProj: false}));
        projectiles.forEach(p => renderList.push({x: p.x, y: p.y, type: 5, isProj: true}));
        renderList.forEach(s => s.dist = ((player.x - s.x)**2 + (player.y - s.y)**2));
        renderList.sort((a,b) => b.dist - a.dist);
    
        renderList.forEach(s => {
            let dx = s.x - player.x, dy = s.y - player.y;
            let invDet = 1.0 / (player.planeX * player.dirY - player.dirX * player.planeY);
            let transformX = invDet * (player.dirY * dx - player.dirX * dy);
            let transformY = invDet * (-player.planeY * dx + player.planeX * dy);
            let spriteScreenX = Math.floor((SCREEN_W / 2) * (1 + transformX / transformY));
            let spriteScale = Math.abs(Math.floor(SCREEN_H / transformY));
            let div = s.isProj ? 4 : 1;
            let size = spriteScale / div;
            
            if(transformY > 0 && transformY < zBuffer[Math.min(Math.max(spriteScreenX, 0), SCREEN_W-1)]) {
                let xPos = spriteScreenX - size/2;
                let yPos = (SCREEN_H/2) - size/2 + (s.isProj ? size : 0);
    
                // FOG: Apply brightness filter based on distance
                // We use transformY (distance) to dim the sprite
                let dist = transformY;
                // Match the fog start/density logic roughly
                let dim = Math.max(0, 100 - (dist - FOG_START) * 20);
                ctx.filter = `brightness(${dim}%)`;

                if (dim > 0) { // Don't draw if fully black
                    if(s.type === 2) { 
                        ctx.fillStyle = s.state > 0 ? "#ff0000" : "#800000";
                        ctx.fillRect(xPos, yPos, size, size);
                        ctx.fillStyle = "#000"; 
                        ctx.fillRect(xPos+size*0.2, yPos+size*0.3, size*0.2, size*0.2);
                        ctx.fillRect(xPos+size*0.6, yPos+size*0.3, size*0.2, size*0.2);
                    } 
                    else if(s.type === 3) {
                        ctx.fillStyle = "#00ff00"; ctx.fillRect(xPos, yPos, size, size);
                        ctx.fillStyle = "#fff"; ctx.fillRect(xPos+size*0.4, yPos+size*0.2, size*0.2, size*0.6);
                        ctx.fillRect(xPos+size*0.2, yPos+size*0.4, size*0.6, size*0.2);
                    }
                    else if(s.type === 4) {
                        ctx.fillStyle = "gold"; ctx.fillRect(xPos, yPos, size, size);
                        ctx.strokeStyle = "#fff"; ctx.lineWidth=2; ctx.strokeRect(xPos, yPos, size, size);
                    }
                    else if(s.type === 5) {
                        ctx.fillStyle = "orange"; ctx.beginPath(); ctx.arc(xPos+size/2, yPos+size/2, size/2, 0, 6.28); ctx.fill();
                    }
                }
                ctx.filter = "none"; // Reset filter
            }
        });
    
        if(flashTimer > 0) { ctx.fillStyle = `rgba(255,255,200,${flashTimer/5})`; ctx.fillRect(0,0,SCREEN_W,SCREEN_H); flashTimer--; }
        ctx.fillStyle = "#555"; ctx.fillRect(SCREEN_W/2 - 20, SCREEN_H - 120, 40, 120);
        ctx.fillStyle = "#222"; ctx.fillRect(SCREEN_W/2 - 5, SCREEN_H - 120, 10, 80);
    }
    
    function gameLoop() { update(); draw(); }
    init();
    </script>    
</body>
</html>

remixes

no remixes yet... email to remix@gameslop.net