Skip to content

Instantly share code, notes, and snippets.

@goldenratio
Created December 30, 2025 12:06
Show Gist options
  • Select an option

  • Save goldenratio/467262f8f3e1724a2d244b833cb4930c to your computer and use it in GitHub Desktop.

Select an option

Save goldenratio/467262f8f3e1724a2d244b833cb4930c to your computer and use it in GitHub Desktop.
fake3d tank
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Wireframe Tank Shooter (Fake 3D)</title>
<style>
html,
body {
margin: 0;
height: 100%;
background: #05070a;
overflow: hidden;
}
canvas {
display: block;
width: 100vw;
height: 100vh;
}
.hud {
position: fixed;
left: 12px;
top: 10px;
color: #b8c7d9;
font: 14px/1.35 system-ui, -apple-system, Segoe UI, Roboto, Arial;
user-select: none;
pointer-events: none;
text-shadow: 0 1px 0 rgba(0, 0, 0, .6);
}
.hud b {
color: #e8f2ff;
}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div class="hud" id="hud"></div>
<script>
(() => {
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const hud = document.getElementById('hud');
const PLAYFIELD_HALF = 40;
// ---------- Math helpers ----------
const TAU = Math.PI * 2;
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const lerp = (a, b, t) => a + (b - a) * t;
function v3(x = 0, y = 0, z = 0) {return {x, y, z};}
function add(a, b) {return v3(a.x + b.x, a.y + b.y, a.z + b.z);}
function sub(a, b) {return v3(a.x - b.x, a.y - b.y, a.z - b.z);}
function mul(a, s) {return v3(a.x * s, a.y * s, a.z * s);}
function dot(a, b) {return a.x * b.x + a.y * b.y + a.z * b.z;}
function len(a) {return Math.hypot(a.x, a.y, a.z);}
function norm(a) {const l = len(a) || 1; return v3(a.x / l, a.y / l, a.z / l);}
function rotY(p, ang) {
const c = Math.cos(ang), s = Math.sin(ang);
return v3(p.x * c - p.z * s, p.y, p.x * s + p.z * c);
}
// ---------- Fake 3D camera + projection ----------
const cam = {
pos: v3(0, 7, -14),
yaw: 0,
pitch: 0.15,
fov: 520, // affects perspective strength
near: 0.2
};
function worldToCamera(P) {
// Translate
let p = sub(P, cam.pos);
// Yaw rotate (around Y)
p = rotY(p, -cam.yaw);
// Pitch rotate (around X)
const c = Math.cos(-cam.pitch), s = Math.sin(-cam.pitch);
const y = p.y * c - p.z * s;
const z = p.y * s + p.z * c;
p = v3(p.x, y, z);
return p;
}
function project(P) {
// P is in WORLD space
const p = worldToCamera(P);
// In our camera space, forward is +z
if (p.z < cam.near) return null;
const x = (p.x / p.z) * cam.fov + canvas.width / 2;
const y = (-p.y / p.z) * cam.fov + canvas.height / 2;
return {x, y, z: p.z};
}
function projectCameraSpace(p) { // p is already in CAMERA space
if (p.z < cam.near) return null;
return {
x: (p.x / p.z) * cam.fov + canvas.width / 2,
y: (-p.y / p.z) * cam.fov + canvas.height / 2,
z: p.z
};
}
function line3(aWorld, bWorld, alpha = 1) {
// Convert endpoints to camera space
let a = worldToCamera(aWorld);
let b = worldToCamera(bWorld);
// If both are behind near plane, skip
if (a.z < cam.near && b.z < cam.near) return;
// If one is behind, clip the segment to z = cam.near
if (a.z < cam.near || b.z < cam.near) {
const t = (cam.near - a.z) / (b.z - a.z); // intersection fraction along a->b
const ix = a.x + (b.x - a.x) * t;
const iy = a.y + (b.y - a.y) * t;
const iz = cam.near;
if (a.z < cam.near) a = {x: ix, y: iy, z: iz};
else b = {x: ix, y: iy, z: iz};
}
const pa = projectCameraSpace(a);
const pb = projectCameraSpace(b);
if (!pa || !pb) return;
ctx.globalAlpha = alpha;
ctx.beginPath();
ctx.moveTo(pa.x, pa.y);
ctx.lineTo(pb.x, pb.y);
ctx.stroke();
}
function polyline3(points, closed = false, alpha = 1) {
const ps = points.map(project);
if (ps.some(p => !p)) return;
ctx.globalAlpha = alpha;
ctx.beginPath();
ctx.moveTo(ps[0].x, ps[0].y);
for (let i = 1; i < ps.length; i++) ctx.lineTo(ps[i].x, ps[i].y);
if (closed) ctx.closePath();
ctx.stroke();
}
// ---------- Input ----------
const keys = new Set();
window.addEventListener('keydown', e => {
keys.add(e.code);
if (e.code === 'Space') e.preventDefault();
if (e.code === 'KeyR') reset();
});
window.addEventListener('keyup', e => keys.delete(e.code));
const mouse = {x: 0, y: 0, down: false};
window.addEventListener('mousemove', e => {
const rect = canvas.getBoundingClientRect();
mouse.x = (e.clientX - rect.left) * (canvas.width / rect.width);
mouse.y = (e.clientY - rect.top) * (canvas.height / rect.height);
});
window.addEventListener('mousedown', () => {mouse.down = true; shoot();});
window.addEventListener('mouseup', () => {mouse.down = false;});
// ---------- Game state ----------
const state = {
t: 0,
score: 0,
hp: 100,
over: false,
enemies: [],
bullets: [],
sparks: []
};
const tank = {
pos: v3(0, 0, 0),
yaw: 0, // hull rotation
turretYaw: 0, // turret rotation
speed: 0
};
function reset() {
state.t = 0;
state.score = 0;
state.hp = 100;
state.over = false;
state.enemies = [];
state.bullets = [];
state.sparks = [];
tank.pos = v3(0, 0, 0);
tank.yaw = 0;
tank.turretYaw = 0;
tank.speed = 0;
spawnWave(6);
}
function spawnWave(n) {
for (let i = 0; i < n; i++) {
const r = 22 + Math.random() * 18;
const a = Math.random() * TAU;
state.enemies.push({
pos: v3(Math.cos(a) * r, 0, Math.sin(a) * r),
yaw: Math.random() * TAU,
hp: 30,
cooldown: 0
});
}
}
// ---------- World helpers ----------
function groundY() {return 0;}
// Ray from screen into ground plane y=0 (approx)
// We invert our simple projection by constructing a camera-space ray.
function screenRayToGround(sx, sy) {
// Convert screen point to normalized camera space direction
const nx = (sx - canvas.width / 2) / cam.fov;
const ny = (sy - canvas.height / 2) / cam.fov;
// In camera space, point at z=1 is (nx, ny, 1)
let dir = norm(v3(nx, ny, 1));
// Undo pitch then yaw to get world-space direction
// inverse of worldToCamera rotations
// Pitch inverse (around X)
{
const c = Math.cos(cam.pitch), s = Math.sin(cam.pitch);
const y = dir.y * c - dir.z * s;
const z = dir.y * s + dir.z * c;
dir = v3(dir.x, y, z);
}
// Yaw inverse (around Y)
dir = rotY(dir, cam.yaw);
// Ray origin is camera pos
const o = cam.pos;
// Intersect with plane y=0: o.y + t*dir.y = 0
const denom = dir.y;
if (Math.abs(denom) < 1e-6) return null;
const t = (groundY() - o.y) / denom;
if (t < 0) return null;
return add(o, mul(dir, t));
}
// ---------- Shooting ----------
let lastShot = 0;
function shoot() {
if (state.over) return;
const now = performance.now();
if (now - lastShot < 140) return; // fire rate
lastShot = now;
const muzzleLocal = v3(0, 0.65, 1.6);
const muzzleWorld = add(tank.pos, rotY(muzzleLocal, tank.turretYaw));
const forward = rotY(v3(0, 0, 1), tank.turretYaw);
state.bullets.push({
pos: muzzleWorld,
vel: mul(forward, 28),
life: 1.6
});
// little recoil sparks
for (let i = 0; i < 10; i++) {
state.sparks.push({
pos: muzzleWorld,
vel: add(mul(forward, 8 + Math.random() * 8), v3((Math.random() - 0.5) * 2, (Math.random() * 2), (Math.random() - 0.5) * 2)),
life: 0.45
});
}
}
// ---------- Rendering wireframe tank ----------
function drawTank(pos, hullYaw, turretYaw, alpha = 1) {
ctx.lineWidth = 1.5;
// Hull box
const hull = {
w: 1.9, h: 0.6, l: 2.6
};
const base = [
v3(-hull.w / 2, 0, -hull.l / 2),
v3(hull.w / 2, 0, -hull.l / 2),
v3(hull.w / 2, 0, hull.l / 2),
v3(-hull.w / 2, 0, hull.l / 2),
];
const top = base.map(p => v3(p.x, p.y + hull.h, p.z));
const baseW = base.map(p => add(pos, rotY(p, hullYaw)));
const topW = top.map(p => add(pos, rotY(p, hullYaw)));
polyline3(baseW, true, alpha);
polyline3(topW, true, alpha);
for (let i = 0; i < 4; i++) line3(baseW[i], topW[i], alpha);
// Treads (simple rectangles on sides)
const treadOffset = hull.w / 2 + 0.25;
const treadL = hull.l * 0.95;
const treadH = 0.45;
function tread(side) {
const x = side * treadOffset;
const b = [
v3(x - 0.18, 0, -treadL / 2),
v3(x + 0.18, 0, -treadL / 2),
v3(x + 0.18, 0, treadL / 2),
v3(x - 0.18, 0, treadL / 2),
].map(p => add(pos, rotY(p, hullYaw)));
const t = b.map(p => add(p, rotY(v3(0, treadH, 0), hullYaw)));
polyline3(b, true, alpha * 0.9);
polyline3(t, true, alpha * 0.9);
for (let i = 0; i < 4; i++) line3(b[i], t[i], alpha * 0.9);
}
tread(-1); tread(1);
// Turret (smaller box)
const turret = {w: 1.2, h: 0.35, l: 1.3};
const turretPos = add(pos, rotY(v3(0, hull.h + 0.05, 0.1), hullYaw));
const tbase = [
v3(-turret.w / 2, 0, -turret.l / 2),
v3(turret.w / 2, 0, -turret.l / 2),
v3(turret.w / 2, 0, turret.l / 2),
v3(-turret.w / 2, 0, turret.l / 2),
];
const ttop = tbase.map(p => v3(p.x, p.y + turret.h, p.z));
const tbaseW = tbase.map(p => add(turretPos, rotY(p, turretYaw)));
const ttopW = ttop.map(p => add(turretPos, rotY(p, turretYaw)));
polyline3(tbaseW, true, alpha);
polyline3(ttopW, true, alpha);
for (let i = 0; i < 4; i++) line3(tbaseW[i], ttopW[i], alpha);
// Cannon
const c0 = add(turretPos, rotY(v3(0, turret.h * 0.65, turret.l / 2), turretYaw));
const c1 = add(turretPos, rotY(v3(0, turret.h * 0.65, turret.l / 2 + 1.6), turretYaw));
const c2 = add(turretPos, rotY(v3(0, turret.h * 0.65 + 0.08, turret.l / 2 + 1.6), turretYaw));
line3(c0, c1, alpha);
line3(c1, c2, alpha * 0.85);
}
function drawEnemy(e) {
// enemy tank-ish wireframe
ctx.lineWidth = 1.4;
drawTank(e.pos, e.yaw, e.yaw, 0.85);
}
function drawBullet(b) {
ctx.lineWidth = 1.2;
const p0 = b.pos;
const p1 = sub(b.pos, mul(norm(b.vel), 0.5));
line3(p1, p0, 0.9);
}
function drawSpark(s) {
ctx.lineWidth = 1.0;
const p1 = sub(s.pos, mul(norm(s.vel), 0.2));
line3(p1, s.pos, 0.5);
}
function drawGroundGrid() {
ctx.lineWidth = 1.0;
const step = 2;
const radius = 40;
// snap grid origin to world-space multiples
const ox = Math.floor(tank.pos.x / step) * step;
const oz = Math.floor(tank.pos.z / step) * step;
for (let x = -radius; x <= radius; x += step) {
const a = v3(ox + x, 0, oz - radius);
const b = v3(ox + x, 0, oz + radius);
line3(a, b, 0.35);
}
for (let z = -radius; z <= radius; z += step) {
const a = v3(ox - radius, 0, oz + z);
const b = v3(ox + radius, 0, oz + z);
line3(a, b, 0.35);
}
}
function drawCrosshair() {
// simple 2D overlay crosshair
const hit = screenRayToGround(mouse.x, mouse.y);
if (!hit) return;
const p = project(hit);
if (!p) return;
ctx.save();
ctx.globalAlpha = 0.8;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(p.x, p.y, 8, 0, TAU);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(p.x - 14, p.y);
ctx.lineTo(p.x - 5, p.y);
ctx.moveTo(p.x + 5, p.y);
ctx.lineTo(p.x + 14, p.y);
ctx.moveTo(p.x, p.y - 14);
ctx.lineTo(p.x, p.y - 5);
ctx.moveTo(p.x, p.y + 5);
ctx.lineTo(p.x, p.y + 14);
ctx.stroke();
ctx.restore();
}
function keepEnemyInBounds(e) {
// If outside, push back and reflect facing direction
if (e.pos.x < -PLAYFIELD_HALF) {e.pos.x = -PLAYFIELD_HALF; e.yaw = Math.PI - e.yaw;}
if (e.pos.x > PLAYFIELD_HALF) {e.pos.x = PLAYFIELD_HALF; e.yaw = Math.PI - e.yaw;}
if (e.pos.z < -PLAYFIELD_HALF) {e.pos.z = -PLAYFIELD_HALF; e.yaw = -e.yaw;}
if (e.pos.z > PLAYFIELD_HALF) {e.pos.z = PLAYFIELD_HALF; e.yaw = -e.yaw;}
}
// ---------- Simulation ----------
function update(dt) {
if (state.over) return;
// Aim turret at mouse-ground intersection
const aimPoint = screenRayToGround(mouse.x, mouse.y);
if (aimPoint) {
const to = sub(aimPoint, tank.pos);
tank.turretYaw = Math.atan2(to.x, to.z);
}
// Drive controls
const forward = keys.has('KeyW') ? 1 : 0;
const back = keys.has('KeyS') ? 1 : 0;
const left = keys.has('KeyA') ? 1 : 0;
const right = keys.has('KeyD') ? 1 : 0;
const targetSpeed = (forward - back) * 8;
tank.speed = lerp(tank.speed, targetSpeed, 1 - Math.pow(0.001, dt));
const turn = (right - left) * (0.9 + 0.15 * Math.abs(tank.speed));
tank.yaw += turn * dt;
const fwd = rotY(v3(0, 0, 1), tank.yaw);
tank.pos = add(tank.pos, mul(fwd, tank.speed * dt));
// Shoot via space
if (keys.has('Space')) shoot();
// Camera follows behind tank (third-person)
{
const behind = rotY(v3(0, 0, -1), tank.yaw);
cam.pos = add(tank.pos, add(mul(behind, 12.5), v3(0, 7.5, 0)));
cam.yaw = tank.yaw;
cam.pitch = 0.22;
}
// Bullets
for (const b of state.bullets) {
b.pos = add(b.pos, mul(b.vel, dt));
b.life -= dt;
}
state.bullets = state.bullets.filter(b => b.life > 0);
// Sparks
for (const s of state.sparks) {
s.pos = add(s.pos, mul(s.vel, dt));
s.vel.y -= 10 * dt;
s.life -= dt;
}
state.sparks = state.sparks.filter(s => s.life > 0);
// Enemies
for (const e of state.enemies) {
// steer toward player
const toP = sub(tank.pos, e.pos);
const d = Math.hypot(toP.x, toP.z);
const desired = Math.atan2(toP.x, toP.z);
let da = ((desired - e.yaw + Math.PI) % TAU) - Math.PI;
e.yaw += clamp(da, -1.6 * dt, 1.6 * dt);
// move
const ef = rotY(v3(0, 0, 1), e.yaw);
const sp = d > 3 ? 3.2 : 0.8;
e.pos = add(e.pos, mul(ef, sp * dt));
keepEnemyInBounds(e);
// enemy fire
e.cooldown -= dt;
if (d < 18 && e.cooldown <= 0) {
e.cooldown = 1.2 + Math.random() * 0.7;
// shoot a slower projectile at player
const muzzle = add(e.pos, rotY(v3(0, 0.65, 1.6), e.yaw));
const dir = norm(v3(toP.x, 0, toP.z));
state.bullets.push({
pos: muzzle,
vel: mul(dir, 18),
life: 1.9,
enemy: true
});
}
}
// Collisions bullet vs enemies / player
for (const b of state.bullets) {
if (b.enemy) {
// hit player
const d = Math.hypot(b.pos.x - tank.pos.x, b.pos.z - tank.pos.z);
if (d < 1.3) {
b.life = -1;
state.hp -= 12;
for (let i = 0; i < 18; i++) {
state.sparks.push({
pos: add(tank.pos, v3((Math.random() - 0.5) * 1.0, 0.6 + Math.random() * 0.6, (Math.random() - 0.5) * 1.0)),
vel: v3((Math.random() - 0.5) * 6, (Math.random() * 7), (Math.random() - 0.5) * 6),
life: 0.55
});
}
}
} else {
// hit enemies
for (const e of state.enemies) {
const d = Math.hypot(b.pos.x - e.pos.x, b.pos.z - e.pos.z);
if (d < 1.3) {
b.life = -1;
e.hp -= 18;
for (let i = 0; i < 18; i++) {
state.sparks.push({
pos: add(e.pos, v3((Math.random() - 0.5) * 1.0, 0.6 + Math.random() * 0.6, (Math.random() - 0.5) * 1.0)),
vel: v3((Math.random() - 0.5) * 6, (Math.random() * 7), (Math.random() - 0.5) * 6),
life: 0.55
});
}
break;
}
}
}
}
state.bullets = state.bullets.filter(b => b.life > 0);
// Remove dead enemies, add score
const before = state.enemies.length;
state.enemies = state.enemies.filter(e => e.hp > 0);
const killed = before - state.enemies.length;
if (killed > 0) state.score += killed * 100;
// Spawn more if empty
if (state.enemies.length === 0) {
spawnWave(6 + Math.floor(state.score / 400));
}
// Game over
if (state.hp <= 0) {
state.hp = 0;
state.over = true;
}
}
// ---------- Render ----------
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Wireframe aesthetic
ctx.strokeStyle = '#d7f1ff';
ctx.fillStyle = '#d7f1ff';
// Subtle background gradient
const g = ctx.createLinearGradient(0, 0, 0, canvas.height);
g.addColorStop(0, '#060a10');
g.addColorStop(1, '#020305');
ctx.fillStyle = g;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = 'rgba(200,240,255,0.95)';
drawGroundGrid();
// Sort objects by depth (painter-ish for nicer overlaps)
const drawables = [];
drawables.push({zHint: len(sub(tank.pos, cam.pos)), fn: () => drawTank(tank.pos, tank.yaw, tank.turretYaw, 1)});
for (const e of state.enemies) {
const zHint = len(sub(e.pos, cam.pos));
drawables.push({zHint, fn: () => drawEnemy(e)});
}
for (const b of state.bullets) {
const zHint = len(sub(b.pos, cam.pos));
drawables.push({zHint, fn: () => drawBullet(b)});
}
for (const s of state.sparks) {
const zHint = len(sub(s.pos, cam.pos));
drawables.push({zHint, fn: () => drawSpark(s)});
}
drawables.sort((a, b) => b.zHint - a.zHint);
for (const d of drawables) d.fn();
drawCrosshair();
// HUD
hud.innerHTML =
`<b>Wireframe Tank Shooter</b><br>` +
`HP: <b>${state.hp}</b> &nbsp; Score: <b>${state.score}</b> &nbsp; Enemies: <b>${state.enemies.length}</b><br>` +
`WASD move • Mouse aim • Click/Space shoot • R restart` +
(state.over ? `<br><br><b>GAME OVER</b> — press <b>R</b>` : ``);
// vignette
ctx.save();
ctx.globalAlpha = 0.18;
ctx.lineWidth = 10;
ctx.strokeStyle = 'rgba(0,0,0,0.8)';
ctx.strokeRect(0, 0, canvas.width, canvas.height);
ctx.restore();
}
// ---------- Loop + resize ----------
function resize() {
const dpr = Math.max(1, Math.min(2, devicePixelRatio || 1));
canvas.width = Math.floor(innerWidth * dpr);
canvas.height = Math.floor(innerHeight * dpr);
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(1, 1);
// keep line widths consistent-ish in HiDPI:
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
}
window.addEventListener('resize', resize);
let last = performance.now();
function frame(now) {
const dt = clamp((now - last) / 1000, 0, 0.033);
last = now;
state.t += dt;
update(dt);
render();
requestAnimationFrame(frame);
}
resize();
reset();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment