Skip to content

Instantly share code, notes, and snippets.

@ankushKun
Created December 24, 2025 14:17
Show Gist options
  • Select an option

  • Save ankushKun/653b7eeaf6d4279ef97f1ab6f709110a to your computer and use it in GitHub Desktop.

Select an option

Save ankushKun/653b7eeaf6d4279ef97f1ab6f709110a to your computer and use it in GitHub Desktop.
Turn a 2D canvas into a 3D scene using clever maths
<!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