Created
December 24, 2025 14:17
-
-
Save ankushKun/653b7eeaf6d4279ef97f1ab6f709110a to your computer and use it in GitHub Desktop.
Turn a 2D canvas into a 3D scene using clever maths
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Engine</title> | |
| <style> | |
| body { | |
| margin: 0; | |
| overflow: hidden; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| height: 100svh; | |
| padding: 0px; | |
| box-sizing: border-box; | |
| background-color: gray; | |
| padding: 20px; | |
| } | |
| canvas { | |
| display: block; | |
| background-color: gray; | |
| width: 100%; | |
| height: 100%; | |
| max-height: 100svh; | |
| max-width: 100svw; | |
| object-fit: contain; | |
| } | |
| #debug { | |
| position: absolute; | |
| top: 2px; | |
| left: 16px; | |
| color: white; | |
| font-family: monospace; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <pre id="debug">hi</pre> | |
| <canvas id="screen" width="640px" height="480px"></canvas> | |
| </body> | |
| <script> | |
| // DOOM LIKE 3D RENDERER | |
| console.log("https://ankush.one") | |
| // CONSTANTS | |
| const screen = document.getElementById("screen") | |
| const ctx = screen.getContext("2d") | |
| const FRAMERATE = 30 | |
| const MOVE_SPEED = 3 | |
| const FOV_DEGREES = 90 // Field of view in degrees (60 = natural, 90 = wide, 100+ = fish-eye) | |
| const NEAR_PLANE = 1 // Minimum render distance | |
| const WORLD_BG = "black" | |
| const WALL_FILL = "lightgray" | |
| const WALL_EDGE = "darkgray" | |
| const FLOOR_COLOR = "#4a4a4a" | |
| const CEILING_COLOR = "#2a2a3a" | |
| const MOVEMENT = { | |
| w: false, | |
| a: false, | |
| s: false, | |
| d: false, | |
| } | |
| // Map configuration | |
| const MAP_WIDTH = 11 | |
| const MAP_HEIGHT = 17 | |
| const TILE_SIZE = 25 // Size of each tile in world units | |
| const WALL_HEIGHT = 40 | |
| // 0 degrees is facing down | |
| // 0,0 is top left | |
| // Grid-based map: 1 = wall, 0 = empty | |
| const MAP = [ | |
| 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, | |
| 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, | |
| 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, | |
| 1, 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, | |
| 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, | |
| 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, | |
| 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, | |
| 1, 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, | |
| 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, | |
| 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, | |
| 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, | |
| 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, | |
| 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, | |
| 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, | |
| 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, | |
| 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, | |
| 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, | |
| ] | |
| const SPAWN = { | |
| x: 5, | |
| y: 2 | |
| } | |
| // Pre-generate walls from map | |
| const WALLS = generateWallsFromMap() | |
| console.log(WALLS.length, "WALLS") | |
| // Player starts in the center of the map (in an empty tile) | |
| const Player = { | |
| x: (SPAWN.x + 0.5) * TILE_SIZE, // Tile (1, 1) center | |
| y: (SPAWN.y + 0.5) * TILE_SIZE, | |
| angle: 0, | |
| } | |
| // HELPER FUNCTIONS | |
| const RAD = Math.PI / 180 | |
| // degree to rad lookup table | |
| const DEG_TO_RAD = Array.from({ length: 360 }, (_, i) => i * RAD) | |
| // Get map tile at grid position (returns 0 for out of bounds - no perimeter wall) | |
| function getMapTile(x, y) { | |
| if (x < 0 || x >= MAP_WIDTH || y < 0 || y >= MAP_HEIGHT) return 0 | |
| return MAP[y * MAP_WIDTH + x] | |
| } | |
| // Collision detection - check if a world position is inside a wall | |
| function isWall(worldX, worldY) { | |
| const tileX = Math.floor(worldX / TILE_SIZE) | |
| const tileY = Math.floor(worldY / TILE_SIZE) | |
| return getMapTile(tileX, tileY) === 1 | |
| } | |
| // Player collision radius (how close to walls the player can get) | |
| const PLAYER_RADIUS = 3 | |
| // Check if player can move to a position (with collision radius) | |
| function canMoveTo(x, y) { | |
| // Check corners of player's collision box | |
| return !isWall(x - PLAYER_RADIUS, y - PLAYER_RADIUS) && | |
| !isWall(x + PLAYER_RADIUS, y - PLAYER_RADIUS) && | |
| !isWall(x - PLAYER_RADIUS, y + PLAYER_RADIUS) && | |
| !isWall(x + PLAYER_RADIUS, y + PLAYER_RADIUS) | |
| } | |
| // Generate walls from grid map - merges adjacent walls to avoid seams | |
| function generateWallsFromMap() { | |
| const color = WALL_FILL // Wall fill color | |
| const edgeColor = WALL_EDGE // Corner edge color | |
| // Track which wall segments we've already processed | |
| const horizontalWalls = [] // [y, startX, endX] | |
| const verticalWalls = [] // [x, startY, endY] | |
| // Find horizontal walls (north/south facing) | |
| for (let y = 0; y <= MAP_HEIGHT; y++) { | |
| let wallStart = null | |
| for (let x = 0; x < MAP_WIDTH; x++) { | |
| const above = getMapTile(x, y - 1) | |
| const below = getMapTile(x, y) | |
| const isWallEdge = (above === 1 && below === 0) || (above === 0 && below === 1) | |
| if (isWallEdge) { | |
| if (wallStart === null) wallStart = x | |
| } else { | |
| if (wallStart !== null) { | |
| horizontalWalls.push([y, wallStart, x, above === 1]) | |
| wallStart = null | |
| } | |
| } | |
| } | |
| if (wallStart !== null) { | |
| horizontalWalls.push([y, wallStart, MAP_WIDTH, getMapTile(MAP_WIDTH - 1, y - 1) === 1]) | |
| } | |
| } | |
| // Find vertical walls (east/west facing) | |
| for (let x = 0; x <= MAP_WIDTH; x++) { | |
| let wallStart = null | |
| for (let y = 0; y < MAP_HEIGHT; y++) { | |
| const left = getMapTile(x - 1, y) | |
| const right = getMapTile(x, y) | |
| const isWallEdge = (left === 1 && right === 0) || (left === 0 && right === 1) | |
| if (isWallEdge) { | |
| if (wallStart === null) wallStart = y | |
| } else { | |
| if (wallStart !== null) { | |
| verticalWalls.push([x, wallStart, y, left === 1]) | |
| wallStart = null | |
| } | |
| } | |
| } | |
| if (wallStart !== null) { | |
| verticalWalls.push([x, wallStart, MAP_HEIGHT, getMapTile(x - 1, MAP_HEIGHT - 1) === 1]) | |
| } | |
| } | |
| // Convert to wall format | |
| const walls = [] | |
| // Add horizontal walls | |
| for (const [y, startX, endX, facingSouth] of horizontalWalls) { | |
| const wy = y * TILE_SIZE | |
| const wx1 = startX * TILE_SIZE | |
| const wx2 = endX * TILE_SIZE | |
| if (facingSouth) { | |
| walls.push([wx2, wy, wx1, wy, WALL_HEIGHT, color, edgeColor]) | |
| } else { | |
| walls.push([wx1, wy, wx2, wy, WALL_HEIGHT, color, edgeColor]) | |
| } | |
| } | |
| // Add vertical walls | |
| for (const [x, startY, endY, facingEast] of verticalWalls) { | |
| const wx = x * TILE_SIZE | |
| const wy1 = startY * TILE_SIZE | |
| const wy2 = endY * TILE_SIZE | |
| if (facingEast) { | |
| walls.push([wx, wy1, wx, wy2, WALL_HEIGHT, color, edgeColor]) | |
| } else { | |
| walls.push([wx, wy2, wx, wy1, WALL_HEIGHT, color, edgeColor]) | |
| } | |
| } | |
| return walls | |
| } | |
| function clearScreen() { | |
| const centerY = screen.height / 2 | |
| // Draw ceiling (top half) | |
| ctx.fillStyle = CEILING_COLOR | |
| ctx.fillRect(0, 0, screen.width, centerY) | |
| // Draw floor (bottom half) | |
| ctx.fillStyle = FLOOR_COLOR | |
| ctx.fillRect(0, centerY, screen.width, centerY) | |
| } | |
| function drawPixel(x, y, color) { | |
| ctx.fillStyle = color | |
| ctx.fillRect(x, y, 1, 1) | |
| } | |
| function drawLine(x1, y1, x2, y2, color) { | |
| ctx.beginPath() | |
| ctx.moveTo(x1, y1) | |
| ctx.lineTo(x2, y2) | |
| ctx.strokeStyle = color | |
| ctx.stroke() | |
| } | |
| function drawRect(x, y, width, height, color) { | |
| ctx.fillStyle = color | |
| ctx.fillRect(x, y, width, height) | |
| } | |
| function drawWall(wx1, wy1, wx2, wy2, height, fillColor, strokeColor = WALL_EDGE) { | |
| // player sin, cos for rotation | |
| const ps = Math.sin(DEG_TO_RAD[Player.angle]) | |
| const pc = Math.cos(DEG_TO_RAD[Player.angle]) | |
| // screen center for projection | |
| const centerX = screen.width / 2 | |
| const centerY = screen.height / 2 | |
| // FOV scaling factor: how many pixels per unit at distance 1 | |
| // tan(FOV/2) = (screenWidth/2) / fov => fov = (screenWidth/2) / tan(FOV/2) | |
| const fov = centerX / Math.tan(DEG_TO_RAD[Math.floor(FOV_DEGREES / 2)]) | |
| // translate wall relative to player position | |
| const relX1 = wx1 - Player.x | |
| const relY1 = wy1 - Player.y | |
| const relX2 = wx2 - Player.x | |
| const relY2 = wy2 - Player.y | |
| // rotate wall around player's view direction | |
| // After rotation: x = left/right, y = depth (forward/backward) | |
| const tx1 = relX1 * pc + relY1 * ps | |
| const ty1 = relY1 * pc - relX1 * ps | |
| const tx2 = relX2 * pc + relY2 * ps | |
| const ty2 = relY2 * pc - relX2 * ps | |
| // CULLING: Skip if wall is completely behind player | |
| if (ty1 <= 0 && ty2 <= 0) { | |
| return | |
| } | |
| // Near-plane clipping: clip wall if partially behind player | |
| let cx1 = tx1, cy1 = ty1, cx2 = tx2, cy2 = ty2 | |
| if (cy1 < NEAR_PLANE) { | |
| const t = (NEAR_PLANE - cy1) / (cy2 - cy1) | |
| cx1 = cx1 + t * (cx2 - cx1) | |
| cy1 = NEAR_PLANE | |
| } | |
| if (cy2 < NEAR_PLANE) { | |
| const t = (NEAR_PLANE - cy2) / (cy1 - cy2) | |
| cx2 = cx2 + t * (cx1 - cx2) | |
| cy2 = NEAR_PLANE | |
| } | |
| // Perspective projection: project to screen coordinates | |
| let sx1 = centerX + (cx1 * fov) / cy1 | |
| let sx2 = centerX + (cx2 * fov) / cy2 | |
| // Calculate wall top and bottom for each endpoint | |
| let wallTop1 = centerY - (height * fov) / cy1 | |
| let wallBot1 = centerY + (height * fov) / cy1 | |
| let wallTop2 = centerY - (height * fov) / cy2 | |
| let wallBot2 = centerY + (height * fov) / cy2 | |
| // CULLING: Skip if wall is completely outside screen horizontally | |
| if ((sx1 < 0 && sx2 < 0) || (sx1 > screen.width && sx2 > screen.width)) { | |
| return | |
| } | |
| // Screen-space clipping: clip wall to screen bounds | |
| // Clip left edge | |
| if (sx1 < 0) { | |
| const t = (0 - sx1) / (sx2 - sx1) | |
| wallTop1 = wallTop1 + t * (wallTop2 - wallTop1) | |
| wallBot1 = wallBot1 + t * (wallBot2 - wallBot1) | |
| sx1 = 0 | |
| } | |
| if (sx2 < 0) { | |
| const t = (0 - sx2) / (sx1 - sx2) | |
| wallTop2 = wallTop2 + t * (wallTop1 - wallTop2) | |
| wallBot2 = wallBot2 + t * (wallBot1 - wallBot2) | |
| sx2 = 0 | |
| } | |
| // Clip right edge | |
| if (sx1 > screen.width) { | |
| const t = (screen.width - sx1) / (sx2 - sx1) | |
| wallTop1 = wallTop1 + t * (wallTop2 - wallTop1) | |
| wallBot1 = wallBot1 + t * (wallBot2 - wallBot1) | |
| sx1 = screen.width | |
| } | |
| if (sx2 > screen.width) { | |
| const t = (screen.width - sx2) / (sx1 - sx2) | |
| wallTop2 = wallTop2 + t * (wallTop1 - wallTop2) | |
| wallBot2 = wallBot2 + t * (wallBot1 - wallBot2) | |
| sx2 = screen.width | |
| } | |
| // Draw the wall as a filled quad | |
| ctx.beginPath() | |
| ctx.moveTo(sx1, wallTop1) | |
| ctx.lineTo(sx2, wallTop2) | |
| ctx.lineTo(sx2, wallBot2) | |
| ctx.lineTo(sx1, wallBot1) | |
| ctx.closePath() | |
| ctx.fillStyle = fillColor | |
| ctx.fill() | |
| // Draw corner edges (vertical lines only) if strokeColor provided | |
| if (strokeColor) { | |
| ctx.strokeStyle = strokeColor | |
| ctx.lineWidth = 1 | |
| // Left edge | |
| ctx.beginPath() | |
| ctx.moveTo(sx1, wallTop1) | |
| ctx.lineTo(sx1, wallBot1) | |
| ctx.stroke() | |
| // Right edge | |
| ctx.beginPath() | |
| ctx.moveTo(sx2, wallTop2) | |
| ctx.lineTo(sx2, wallBot2) | |
| ctx.stroke() | |
| ctx.lineWidth = 1 | |
| } | |
| } | |
| // MAIN LOOP | |
| function movePlayer() { | |
| // this is a 3d coordinate space | |
| // Player.angle is the direction they are looking at | |
| // w should move them in the direction they are facing | |
| // s should move them in the opposite direction they are facing | |
| // a should move them to the left | |
| // d should move them to the right | |
| // if I am facing 0 degree, y should increase on pressing w | |
| // if I rotate 90 degrees, x should increase on pressing w | |
| // if I rotate 180 degrees, y should decrease on pressing w | |
| // if I rotate 270 degrees, x should decrease on pressing w | |
| // based on angle increment or decrement the player x and y | |
| // x and y cannot be less than 0 | |
| // Forward direction matches view: (-sin(angle), cos(angle)) | |
| const dx = -Math.sin(DEG_TO_RAD[Player.angle]) * MOVE_SPEED | |
| const dy = Math.cos(DEG_TO_RAD[Player.angle]) * MOVE_SPEED | |
| let moveX = 0 | |
| let moveY = 0 | |
| if (MOVEMENT.w) { | |
| moveX += dx | |
| moveY += dy | |
| } | |
| if (MOVEMENT.s) { | |
| moveX -= dx | |
| moveY -= dy | |
| } | |
| if (MOVEMENT.a) { | |
| moveX -= dy | |
| moveY += dx | |
| } | |
| if (MOVEMENT.d) { | |
| moveX += dy | |
| moveY -= dx | |
| } | |
| // Normalize diagonal movement to prevent faster speed | |
| const moveMagnitude = Math.sqrt(moveX * moveX + moveY * moveY) | |
| if (moveMagnitude > 0) { | |
| moveX = (moveX / moveMagnitude) * MOVE_SPEED | |
| moveY = (moveY / moveMagnitude) * MOVE_SPEED | |
| } | |
| // Apply movement with collision detection (allows wall sliding) | |
| const newX = Player.x + moveX | |
| const newY = Player.y + moveY | |
| // Try moving in both directions | |
| if (canMoveTo(newX, newY)) { | |
| Player.x = newX | |
| Player.y = newY | |
| } | |
| // If blocked, try moving in X only (slide along Y wall) | |
| else if (canMoveTo(newX, Player.y)) { | |
| Player.x = newX | |
| } | |
| // If blocked, try moving in Y only (slide along X wall) | |
| else if (canMoveTo(Player.x, newY)) { | |
| Player.y = newY | |
| } | |
| // Otherwise, fully blocked - don't move | |
| } | |
| function draw3D() { | |
| // Calculate distance from player using combined metric | |
| const wallsWithDistance = WALLS.map(wall => { | |
| const [x1, y1, x2, y2] = wall | |
| // Calculate distance from player to both endpoints | |
| const dx1 = x1 - Player.x | |
| const dy1 = y1 - Player.y | |
| const dx2 = x2 - Player.x | |
| const dy2 = y2 - Player.y | |
| const dist1 = dx1 * dx1 + dy1 * dy1 | |
| const dist2 = dx2 * dx2 + dy2 * dy2 | |
| // Calculate midpoint distance | |
| const midX = (x1 + x2) / 2 | |
| const midY = (y1 + y2) / 2 | |
| const dxMid = midX - Player.x | |
| const dyMid = midY - Player.y | |
| const distMid = dxMid * dxMid + dyMid * dyMid | |
| // Combined metric: weighted average of min, mid, and max | |
| const minDist = Math.min(dist1, dist2) | |
| const maxDist = Math.max(dist1, dist2) | |
| // Weight: closest point most important, then mid, then max | |
| const distance = minDist * 0.5 + distMid * 0.3 + maxDist * 0.2 | |
| return { wall, distance } | |
| }) | |
| // Sort by distance: farthest first (painter's algorithm) | |
| wallsWithDistance.sort((a, b) => b.distance - a.distance) | |
| // Draw walls in sorted order (far to near) | |
| for (const { wall } of wallsWithDistance) { | |
| drawWall(...wall) | |
| } | |
| } | |
| function debug() { | |
| const debug = document.getElementById("debug") | |
| const str = `Player\nx: ${Math.round(Player.x)}\ny: ${Math.round(Player.y)}\nangle: ${Player.angle}` | |
| debug.textContent = str | |
| } | |
| function loop() { | |
| clearScreen() | |
| movePlayer() | |
| draw3D() | |
| debug() | |
| } | |
| // BTS LOGIC | |
| // Pointer lock state | |
| let isMouseLocked = false | |
| function lockMouse() { | |
| screen.requestPointerLock() | |
| } | |
| function unlockMouse() { | |
| document.exitPointerLock() | |
| } | |
| (async () => { | |
| // Pointer lock event listeners | |
| document.addEventListener("pointerlockchange", () => { | |
| isMouseLocked = document.pointerLockElement === screen | |
| }) | |
| // Click canvas to lock mouse | |
| screen.addEventListener("click", () => { | |
| if (!isMouseLocked) { | |
| lockMouse() | |
| } | |
| }) | |
| document.addEventListener("keydown", (event) => { | |
| switch (event.key) { | |
| case "w": case "W": | |
| MOVEMENT.w = true | |
| break | |
| case "s": case "S": | |
| MOVEMENT.s = true | |
| break | |
| case "a": case "A": | |
| MOVEMENT.a = true | |
| break | |
| case "d": case "D": | |
| MOVEMENT.d = true | |
| break | |
| case "Escape": | |
| if (isMouseLocked) { | |
| unlockMouse() | |
| } | |
| break | |
| } | |
| }) | |
| document.addEventListener("keyup", (event) => { | |
| switch (event.key) { | |
| case "w": case "W": | |
| MOVEMENT.w = false | |
| break | |
| case "s": case "S": | |
| MOVEMENT.s = false | |
| break | |
| case "a": case "A": | |
| MOVEMENT.a = false | |
| break | |
| case "d": case "D": | |
| MOVEMENT.d = false | |
| break | |
| } | |
| }) | |
| document.addEventListener("mousemove", (event) => { | |
| // Only move camera when mouse is locked | |
| if (!isMouseLocked) return | |
| // convert mouse move into degree change and update player angle | |
| const mouseMove = event.movementX | |
| const degreeChange = mouseMove / screen.width * 360 | |
| Player.angle -= degreeChange | |
| // clamp angle to 0-359 (handles any value, positive or negative) | |
| Player.angle = Math.round(((Player.angle % 360) + 360) % 360) | |
| }) | |
| while (true) { | |
| loop() | |
| await new Promise((resolve) => setTimeout(resolve, 1000 / FRAMERATE)) | |
| } | |
| })() | |
| </script> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment