
First-person shooter
You see the world through your character's eyes, with the weapon in the foreground, and move freely through 3D spaces while aiming and shooting at enemies.
Also known as: FPS, Doom clone
Defining mechanics
- A camera at the character's eyes and a fixed crosshair in the centre of the screen, so aiming means turning your whole view.
- Free movement through the level (forward, back, turning and sidestepping) while shooting, so you can dodge without losing your aim.
- Several weapons with different ranges, rates of fire and ammunition, and the constant choice of which to use against each enemy.
- Health, armour and ammunition as resources picked up around the level, or a shield that recharges if you avoid damage for a moment.
- Levels with keys, doors, secrets and ambushes, and, in multiplayer, deathmatches on maps built for combat.
Gameplay loop
Move through the level, spot an enemy, turn to put it under the crosshair, shoot while moving to avoid damage, pick up health and ammo, and press on to the next area.
Our demo
Controls. Arrows or WASD to turn and move forward or back, space, X or click to shoot. On mobile, the on-screen buttons.
Open full screenA small first-person maze drawn with ray casting, the technique of Wolfenstein 3D (1992): the world is a flat grid and the 3D is an illusion worked out column by column. Two enemies close in on you, you shoot with the central crosshair and you win by reaching the green door.
Made with Kaplay, in about 216 lines of JavaScript.
How it's made
Setup and controls
The game is created and virtual buttons are defined to turn, move forward, move back and shoot. FOV is the field of view (60°) and RAYS the number of rays, one per screen column. They are the two numbers that most change the image.
const k = kaplay({ width: 640, height: 360, letterbox: true, pixelDensity, background: [0, 0, 0], buttons: { left: { keyboard: ["left", "a"] }, right: { keyboard: ["right", "d"] }, forward: { keyboard: ["up", "w"] }, back: { keyboard: ["down", "s"] }, fire: { keyboard: ["space", "x"] }, start: { keyboard: ["enter"] }, // space is already "fire", which also starts }, }); touchControls(k, [ { button: "left", label: "◀" }, { button: "forward", label: "▲" }, { button: "back", label: "▼" }, { button: "right", label: "▶" }, { button: "fire", label: "✹", side: "right" }, ]); const W = 640; const H = 360; const FOV = Math.PI / 3; // 60° field of view const RAYS = 160; // one ray per screen column… const COL_W = W / RAYS; // …and each column is 4 px wide const MOVE_SPEED = 2.6; // tiles per second const TURN_SPEED = 2.2; // radians per second const ENEMY_SPEED = 1.1;The map is a flat grid
The world is a list of strings seen from above, just like a 2D game level. Positions are measured in tiles, so the player and the enemies can be in the middle of a cell (2.5 is the centre of cell 2). To know whether there is a wall, you just round down and look at the character.
// The world is a grid seen from above: # wall · X exit door · P start · E enemy. // Positions are measured in tiles, so (2.5, 2.5) is the centre of a cell. const MAP = [ "############", "#..........#", "#.P....E...#", "#..........#", "##.###.###.#", "#.....#....#", "#.###.#.E..#", "#.#...#....#", "#...#...##.#", "##########X#", ]; const cellAt = (x, y) => MAP[Math.floor(y)]?.[Math.floor(x)] ?? "#"; const isWall = (x, y) => "#X".includes(cellAt(x, y)); const WALL_COLOR = { "#": [110, 130, 190], X: [70, 210, 110] };The ray and the DDA algorithm
This is the heart of the demo. A ray is cast from the player and followed cell by cell, always jumping to the nearest grid line, in x or in y. This is the DDA algorithm. Since it only visits the cells the ray crosses, it is very fast. When it hits, it returns the distance and the side of the wall. The shade function darkens with distance and a little more on north and south faces, a cheap trick that makes corners readable.
// The trick of 1992: the world is flat (a 2D grid) and the "3D" is an // illusion. For every screen column we cast one ray from the player and walk // it cell by cell (the DDA algorithm) until it hits a wall. The farther the // wall, the shorter we draw that column. function castRay(ox, oy, angle) { const dx = Math.cos(angle); const dy = Math.sin(angle); let mapX = Math.floor(ox); let mapY = Math.floor(oy); const stepX = dx < 0 ? -1 : 1; const stepY = dy < 0 ? -1 : 1; // How far along the ray we must travel to cross one whole cell in x or in y. const deltaX = Math.abs(1 / dx); const deltaY = Math.abs(1 / dy); // Distance to the first vertical and the first horizontal grid line. let sideX = (dx < 0 ? ox - mapX : mapX + 1 - ox) * deltaX; let sideY = (dy < 0 ? oy - mapY : mapY + 1 - oy) * deltaY; for (let i = 0; i < 64; i++) { // Always jump to whichever grid line is closer: that is the next cell. let dist, side; if (sideX < sideY) { mapX += stepX; dist = sideX; sideX += deltaX; side = 0; // hit a vertical (east/west-facing) wall } else { mapY += stepY; dist = sideY; sideY += deltaY; side = 1; // hit a horizontal (north/south-facing) wall } const cell = cellAt(mapX, mapY); if ("#X".includes(cell)) return { dist, side, cell }; } return { dist: 64, side: 0, cell: "#" }; } // Darker with distance, and walls facing north/south a bit darker than // east/west ones: this cheap trick is what makes the corners readable. function shade([r, g, b], dist, side) { const light = (side ? 0.7 : 1) / (1 + dist * dist * 0.04); return rgb(r * light, g * light, b * light); }The player is the camera
The player has no shape, it is a point with a viewing angle. Turning changes the angle and moving forward adds its cosine and sine to the position. Movement is tested separately in x and in y, so if one axis hits a wall you slide along it instead of getting stuck. Bumping into the green door wins the game.
// The player is a point on the map with a viewing angle (0 = east, // PI/2 = south, because y grows downwards). It has no shape: it is the camera. const player = add([pos(2.5, 2.5), "player", { angle: 0, health: 100, cooldown: 0, flash: 0, hurt: 0 }]); // Move along x and along y separately: if one axis is blocked you slide along the wall. function tryMove(obj, dx, dy, radius) { if (!isWall(obj.pos.x + dx + Math.sign(dx) * radius, obj.pos.y)) obj.pos.x += dx; if (!isWall(obj.pos.x, obj.pos.y + dy + Math.sign(dy) * radius)) obj.pos.y += dy; } onUpdate(() => { if (isButtonDown("left")) player.angle -= TURN_SPEED * dt(); if (isButtonDown("right")) player.angle += TURN_SPEED * dt(); const dir = (isButtonDown("forward") ? 1 : 0) - (isButtonDown("back") ? 1 : 0); const step = dir * MOVE_SPEED * dt(); const dx = Math.cos(player.angle) * step; const dy = Math.sin(player.angle) * step; // Walking into the exit door wins. if (dir && cellAt(player.pos.x + Math.cos(player.angle) * 0.4 * dir, player.pos.y + Math.sin(player.angle) * 0.4 * dir) === "X") go("end", true); tryMove(player, dx, dy, 0.25); player.cooldown -= dt(); player.flash -= dt(); player.hurt -= dt(); });Enemies that can see you
Enemies are not part of the grid, they are loose objects on the map. To decide whether to chase you they reuse the same raycast. If a ray towards you hits a wall before reaching you, they cannot see you. If they touch you, you lose health little by little.
// Enemies are not part of the grid: they are "billboards", flat pictures that // always face you. They walk towards you while they can see you. for (let y = 0; y < MAP.length; y++) for (let x = 0; x < MAP[y].length; x++) if (MAP[y][x] === "E") add([pos(x + 0.5, y + 0.5), "enemy", { hp: 2, hit: 0, screen: null }]); // Line of sight: if a ray towards the player hits a wall first, the enemy cannot see you. const canSee = (e, dist) => castRay(e.pos.x, e.pos.y, Math.atan2(player.pos.y - e.pos.y, player.pos.x - e.pos.x)).dist > dist; onUpdate("enemy", (e) => { e.hit -= dt(); const dist = e.pos.dist(player.pos); if (dist > 0.7 && dist < 9 && canSee(e, dist)) { const toPlayer = player.pos.sub(e.pos).unit().scale(ENEMY_SPEED * dt()); tryMove(e, toPlayer.x, toPlayer.y, 0.3); } if (dist < 0.8) { player.health -= 20 * dt(); // touching hurts, little by little player.hurt = 0.1; if (player.health <= 0) go("end", false); } });Hitscan shooting
The shot is not a projectile, it is instant (hitscan). It hits the nearest enemy under the crosshair, as long as the wall in the centre column is not closer than it. Each enemy takes two hits and flashes white when hit, a visible reaction that makes shooting feel good.
// "Hitscan": the shot is instant. It hits the nearest enemy under the // crosshair, as long as no wall in the centre column is closer than it. function fire() { if (player.cooldown > 0) return; player.cooldown = 0.35; player.flash = 0.08; const wallDist = castRay(player.pos.x, player.pos.y, player.angle).dist; const target = get("enemy") .filter((e) => e.screen && Math.abs(e.screen.x - W / 2) < e.screen.size * 0.25 && e.screen.depth < wallDist) .sort((a, b) => a.screen.depth - b.screen.depth)[0]; if (!target) return; target.hit = 0.15; if (--target.hp <= 0) destroy(target); } onButtonPress("fire", fire); onMousePress(fire);Drawing the illusion
Everything is drawn by hand every frame. First the ceiling and the floor, two rectangles. Then, for each column, a ray and a vertical rectangle whose height is the screen height divided by the distance, so something twice as far looks half as tall. The distance is multiplied by the cosine of the ray's angle to correct the fisheye effect. Then come the enemies, from farthest to nearest, cut into strips that are only drawn where they are closer than that column's wall, which is what the depth array stores. The minimap shows the truth behind the illusion: a flat grid and a fan of rays.
// A custom draw component: everything is drawn by hand each frame. const depth = new Array(RAYS); // distance of the wall in every column add([ { draw() { drawRect({ pos: vec2(0, 0), width: W, height: H / 2, color: rgb(40, 40, 52) }); // ceiling drawRect({ pos: vec2(0, H / 2), width: W, height: H / 2, color: rgb(70, 62, 54) }); // floor // 1) Walls: one ray and one vertical rectangle per column. for (let i = 0; i < RAYS; i++) { const camX = (2 * (i + 0.5)) / RAYS - 1; // -1 at the left edge, +1 at the right const rayAngle = player.angle + Math.atan(camX * Math.tan(FOV / 2)); const hit = castRay(player.pos.x, player.pos.y, rayAngle); // Fisheye fix: use the distance perpendicular to the view, not along the ray. const dist = hit.dist * Math.cos(rayAngle - player.angle); depth[i] = dist; const h = Math.min(H * 3, H / dist); // twice as far, half as tall drawRect({ pos: vec2(i * COL_W, (H - h) / 2), width: COL_W + 0.5, height: h, color: shade(WALL_COLOR[hit.cell], dist, hit.side) }); } // 2) Enemies: sorted far to near, sliced into columns that are only // drawn where the enemy is closer than the wall (the depth buffer). const enemies = get("enemy"); for (const e of enemies) { const rel = Math.atan2(e.pos.y - player.pos.y, e.pos.x - player.pos.x) - player.angle; const a = Math.atan2(Math.sin(rel), Math.cos(rel)); // wrap to -PI…PI const d = e.pos.dist(player.pos) * Math.cos(a); e.screen = Math.abs(a) < FOV && d > 0.2 ? { x: W / 2 + (Math.tan(a) / Math.tan(FOV / 2)) * (W / 2), size: (H / d) * 0.7, depth: d } : null; } for (const e of enemies.filter((o) => o.screen).sort((p, q) => q.screen.depth - p.screen.depth)) { const { x, size, depth: d } = e.screen; const top = H / 2 + H / d / 2 - size; // feet on the floor const body = shade(e.hit > 0 ? [255, 255, 255] : [210, 60, 60], d, 0); const half = size * 0.25; // a figure half as wide as it is tall for (let c = Math.max(0, Math.floor((x - half) / COL_W)); c < Math.min(RAYS, (x + half) / COL_W); c++) { if (depth[c] < d) continue; // a wall is in front of this slice const head = Math.abs(c * COL_W + COL_W / 2 - x) < half * 0.5; // the middle slices also get a head drawRect({ pos: vec2(c * COL_W, top + size * (head ? 0 : 0.25)), width: COL_W + 0.5, height: size * (head ? 1 : 0.75), color: body }); if (head) drawRect({ pos: vec2(c * COL_W, top + size * 0.08), width: COL_W + 0.5, height: size * 0.06, color: rgb(30, 20, 20) }); } } // 3) Weapon, muzzle flash and crosshair. if (player.flash > 0) drawCircle({ pos: vec2(W / 2, H - 70), radius: 26, color: rgb(255, 220, 120) }); drawRect({ pos: vec2(W / 2 - 14, H - 64), width: 28, height: 64, color: rgb(60, 60, 70) }); drawLine({ p1: vec2(W / 2 - 8, H / 2), p2: vec2(W / 2 + 8, H / 2), width: 2, color: rgb(255, 255, 255) }); drawLine({ p1: vec2(W / 2, H / 2 - 8), p2: vec2(W / 2, H / 2 + 8), width: 2, color: rgb(255, 255, 255) }); if (player.hurt > 0) drawRect({ pos: vec2(0, 0), width: W, height: H, color: rgb(255, 0, 0), opacity: 0.25 }); drawMinimap(enemies); }, }, ]); // The minimap shows the truth behind the illusion: a flat grid, and a fan of rays. function drawMinimap(enemies) { const S = 7; const o = vec2(W - MAP[0].length * S - 10, 10); for (let y = 0; y < MAP.length; y++) for (let x = 0; x < MAP[y].length; x++) if (isWall(x, y)) drawRect({ pos: o.add(x * S, y * S), width: S, height: S, color: rgb(...WALL_COLOR[MAP[y][x]]), opacity: 0.7 }); const p = o.add(player.pos.scale(S)); for (let i = 0; i < RAYS; i += 20) { const camX = (2 * (i + 0.5)) / RAYS - 1; const a = player.angle + Math.atan(camX * Math.tan(FOV / 2)); const len = depth[i] / Math.cos(a - player.angle); drawLine({ p1: p, p2: p.add(Math.cos(a) * len * S, Math.sin(a) * len * S), width: 1, color: rgb(255, 230, 120), opacity: 0.6 }); } for (const e of enemies) drawCircle({ pos: o.add(e.pos.scale(S)), radius: 2, color: rgb(230, 70, 70) }); drawCircle({ pos: p, radius: 2.5, color: rgb(255, 255, 255) }); }HUD
Health and the remaining enemies are shown on top of the view, because in first person you cannot see your character and the screen has to tell you how you are doing.
const hud = add([text("", { size: 16, font: FONT }), pos(12, 10), z(10)]); hud.onUpdate(() => { hud.text = t({ es: "Salud ", en: "Health " }) + Math.max(0, Math.ceil(player.health)) + " " + t({ es: "Enemigos ", en: "Enemies " }) + get("enemy").length; });Scenes
Three scenes (title, game and end) and go() to move between them. Going back to game rebuilds the level from scratch.
function screen(title, subtitle) { add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 40)), anchor("center")]); add([text(subtitle, { size: 16, font: FONT, align: "center", width: 560 }), pos(center().add(0, 30)), anchor("center"), color(200, 210, 255)]); onButtonPress("start", () => go("game")); onButtonPress("fire", () => go("game")); onMousePress(() => go("game")); } scene("title", () => screen( t({ es: "Disparos en primera persona", en: "First-person shooter" }), t({ es: "Flechas o WASD para girar y avanzar, espacio o clic para disparar.\nEncuentra la puerta verde de salida.\nPulsa espacio o toca para empezar.", en: "Arrows or WASD to turn and move, space or click to shoot.\nFind the green exit door.\nPress space or tap to start.", }), ), ); scene("end", (won) => screen( won ? t({ es: "¡Saliste!", en: "You got out!" }) : t({ es: "Te alcanzaron", en: "They got you" }), t({ es: "Pulsa espacio o toca para jugar otra vez.", en: "Press space or tap to play again." }), ), ); go("title");
What we simplified
- Walls are plain colours with no textures; texturing means choosing, for each column, which vertical strip of the image to draw depending on where the ray hit.
- There is no sidestepping, ammo or opening doors. Just one weapon and two enemies that chase you if they can see you.
- Enemies are flat rectangles that always face you (billboards), like the sprites in 1992 games.
- One 4-pixel column is drawn per ray (160 rays), not one per pixel, so it runs on modest computers.
To modify it
- Add sidestepping (strafe) with two new keys. Hint, it is moving forward with the angle plus 90°.
- Create a new wall type (another character and another colour in WALL_COLOR) and use it so the player recognises each area of the map.
- Add limited ammo and bullet boxes on the map that refill it. How does it change the way you play?
Full code
// First-person shooter demo: a 1992-style raycaster
// Engine: Kaplay 3001 (https://kaplayjs.com). MIT licence.
import kaplay from "../../vendor/kaplay.mjs";
import { t, FONT, touchControls, pixelDensity } from "../common.js";
// #region setup
const k = kaplay({
width: 640,
height: 360,
letterbox: true, pixelDensity,
background: [0, 0, 0],
buttons: {
left: { keyboard: ["left", "a"] },
right: { keyboard: ["right", "d"] },
forward: { keyboard: ["up", "w"] },
back: { keyboard: ["down", "s"] },
fire: { keyboard: ["space", "x"] },
start: { keyboard: ["enter"] }, // space is already "fire", which also starts
},
});
touchControls(k, [
{ button: "left", label: "◀" },
{ button: "forward", label: "▲" },
{ button: "back", label: "▼" },
{ button: "right", label: "▶" },
{ button: "fire", label: "✹", side: "right" },
]);
const W = 640;
const H = 360;
const FOV = Math.PI / 3; // 60° field of view
const RAYS = 160; // one ray per screen column…
const COL_W = W / RAYS; // …and each column is 4 px wide
const MOVE_SPEED = 2.6; // tiles per second
const TURN_SPEED = 2.2; // radians per second
const ENEMY_SPEED = 1.1;
// #endregion
// #region map
// The world is a grid seen from above: # wall · X exit door · P start · E enemy.
// Positions are measured in tiles, so (2.5, 2.5) is the centre of a cell.
const MAP = [
"############",
"#..........#",
"#.P....E...#",
"#..........#",
"##.###.###.#",
"#.....#....#",
"#.###.#.E..#",
"#.#...#....#",
"#...#...##.#",
"##########X#",
];
const cellAt = (x, y) => MAP[Math.floor(y)]?.[Math.floor(x)] ?? "#";
const isWall = (x, y) => "#X".includes(cellAt(x, y));
const WALL_COLOR = { "#": [110, 130, 190], X: [70, 210, 110] };
// #endregion
// #region raycast
// The trick of 1992: the world is flat (a 2D grid) and the "3D" is an
// illusion. For every screen column we cast one ray from the player and walk
// it cell by cell (the DDA algorithm) until it hits a wall. The farther the
// wall, the shorter we draw that column.
function castRay(ox, oy, angle) {
const dx = Math.cos(angle);
const dy = Math.sin(angle);
let mapX = Math.floor(ox);
let mapY = Math.floor(oy);
const stepX = dx < 0 ? -1 : 1;
const stepY = dy < 0 ? -1 : 1;
// How far along the ray we must travel to cross one whole cell in x or in y.
const deltaX = Math.abs(1 / dx);
const deltaY = Math.abs(1 / dy);
// Distance to the first vertical and the first horizontal grid line.
let sideX = (dx < 0 ? ox - mapX : mapX + 1 - ox) * deltaX;
let sideY = (dy < 0 ? oy - mapY : mapY + 1 - oy) * deltaY;
for (let i = 0; i < 64; i++) {
// Always jump to whichever grid line is closer: that is the next cell.
let dist, side;
if (sideX < sideY) {
mapX += stepX;
dist = sideX;
sideX += deltaX;
side = 0; // hit a vertical (east/west-facing) wall
} else {
mapY += stepY;
dist = sideY;
sideY += deltaY;
side = 1; // hit a horizontal (north/south-facing) wall
}
const cell = cellAt(mapX, mapY);
if ("#X".includes(cell)) return { dist, side, cell };
}
return { dist: 64, side: 0, cell: "#" };
}
// Darker with distance, and walls facing north/south a bit darker than
// east/west ones: this cheap trick is what makes the corners readable.
function shade([r, g, b], dist, side) {
const light = (side ? 0.7 : 1) / (1 + dist * dist * 0.04);
return rgb(r * light, g * light, b * light);
}
// #endregion
scene("game", () => {
// #region player
// The player is a point on the map with a viewing angle (0 = east,
// PI/2 = south, because y grows downwards). It has no shape: it is the camera.
const player = add([pos(2.5, 2.5), "player", { angle: 0, health: 100, cooldown: 0, flash: 0, hurt: 0 }]);
// Move along x and along y separately: if one axis is blocked you slide along the wall.
function tryMove(obj, dx, dy, radius) {
if (!isWall(obj.pos.x + dx + Math.sign(dx) * radius, obj.pos.y)) obj.pos.x += dx;
if (!isWall(obj.pos.x, obj.pos.y + dy + Math.sign(dy) * radius)) obj.pos.y += dy;
}
onUpdate(() => {
if (isButtonDown("left")) player.angle -= TURN_SPEED * dt();
if (isButtonDown("right")) player.angle += TURN_SPEED * dt();
const dir = (isButtonDown("forward") ? 1 : 0) - (isButtonDown("back") ? 1 : 0);
const step = dir * MOVE_SPEED * dt();
const dx = Math.cos(player.angle) * step;
const dy = Math.sin(player.angle) * step;
// Walking into the exit door wins.
if (dir && cellAt(player.pos.x + Math.cos(player.angle) * 0.4 * dir, player.pos.y + Math.sin(player.angle) * 0.4 * dir) === "X") go("end", true);
tryMove(player, dx, dy, 0.25);
player.cooldown -= dt();
player.flash -= dt();
player.hurt -= dt();
});
// #endregion
// #region enemies
// Enemies are not part of the grid: they are "billboards", flat pictures that
// always face you. They walk towards you while they can see you.
for (let y = 0; y < MAP.length; y++)
for (let x = 0; x < MAP[y].length; x++)
if (MAP[y][x] === "E") add([pos(x + 0.5, y + 0.5), "enemy", { hp: 2, hit: 0, screen: null }]);
// Line of sight: if a ray towards the player hits a wall first, the enemy cannot see you.
const canSee = (e, dist) => castRay(e.pos.x, e.pos.y, Math.atan2(player.pos.y - e.pos.y, player.pos.x - e.pos.x)).dist > dist;
onUpdate("enemy", (e) => {
e.hit -= dt();
const dist = e.pos.dist(player.pos);
if (dist > 0.7 && dist < 9 && canSee(e, dist)) {
const toPlayer = player.pos.sub(e.pos).unit().scale(ENEMY_SPEED * dt());
tryMove(e, toPlayer.x, toPlayer.y, 0.3);
}
if (dist < 0.8) {
player.health -= 20 * dt(); // touching hurts, little by little
player.hurt = 0.1;
if (player.health <= 0) go("end", false);
}
});
// #endregion
// #region shooting
// "Hitscan": the shot is instant. It hits the nearest enemy under the
// crosshair, as long as no wall in the centre column is closer than it.
function fire() {
if (player.cooldown > 0) return;
player.cooldown = 0.35;
player.flash = 0.08;
const wallDist = castRay(player.pos.x, player.pos.y, player.angle).dist;
const target = get("enemy")
.filter((e) => e.screen && Math.abs(e.screen.x - W / 2) < e.screen.size * 0.25 && e.screen.depth < wallDist)
.sort((a, b) => a.screen.depth - b.screen.depth)[0];
if (!target) return;
target.hit = 0.15;
if (--target.hp <= 0) destroy(target);
}
onButtonPress("fire", fire);
onMousePress(fire);
// #endregion
// #region render
// A custom draw component: everything is drawn by hand each frame.
const depth = new Array(RAYS); // distance of the wall in every column
add([
{
draw() {
drawRect({ pos: vec2(0, 0), width: W, height: H / 2, color: rgb(40, 40, 52) }); // ceiling
drawRect({ pos: vec2(0, H / 2), width: W, height: H / 2, color: rgb(70, 62, 54) }); // floor
// 1) Walls: one ray and one vertical rectangle per column.
for (let i = 0; i < RAYS; i++) {
const camX = (2 * (i + 0.5)) / RAYS - 1; // -1 at the left edge, +1 at the right
const rayAngle = player.angle + Math.atan(camX * Math.tan(FOV / 2));
const hit = castRay(player.pos.x, player.pos.y, rayAngle);
// Fisheye fix: use the distance perpendicular to the view, not along the ray.
const dist = hit.dist * Math.cos(rayAngle - player.angle);
depth[i] = dist;
const h = Math.min(H * 3, H / dist); // twice as far, half as tall
drawRect({ pos: vec2(i * COL_W, (H - h) / 2), width: COL_W + 0.5, height: h, color: shade(WALL_COLOR[hit.cell], dist, hit.side) });
}
// 2) Enemies: sorted far to near, sliced into columns that are only
// drawn where the enemy is closer than the wall (the depth buffer).
const enemies = get("enemy");
for (const e of enemies) {
const rel = Math.atan2(e.pos.y - player.pos.y, e.pos.x - player.pos.x) - player.angle;
const a = Math.atan2(Math.sin(rel), Math.cos(rel)); // wrap to -PI…PI
const d = e.pos.dist(player.pos) * Math.cos(a);
e.screen = Math.abs(a) < FOV && d > 0.2 ? { x: W / 2 + (Math.tan(a) / Math.tan(FOV / 2)) * (W / 2), size: (H / d) * 0.7, depth: d } : null;
}
for (const e of enemies.filter((o) => o.screen).sort((p, q) => q.screen.depth - p.screen.depth)) {
const { x, size, depth: d } = e.screen;
const top = H / 2 + H / d / 2 - size; // feet on the floor
const body = shade(e.hit > 0 ? [255, 255, 255] : [210, 60, 60], d, 0);
const half = size * 0.25; // a figure half as wide as it is tall
for (let c = Math.max(0, Math.floor((x - half) / COL_W)); c < Math.min(RAYS, (x + half) / COL_W); c++) {
if (depth[c] < d) continue; // a wall is in front of this slice
const head = Math.abs(c * COL_W + COL_W / 2 - x) < half * 0.5; // the middle slices also get a head
drawRect({ pos: vec2(c * COL_W, top + size * (head ? 0 : 0.25)), width: COL_W + 0.5, height: size * (head ? 1 : 0.75), color: body });
if (head) drawRect({ pos: vec2(c * COL_W, top + size * 0.08), width: COL_W + 0.5, height: size * 0.06, color: rgb(30, 20, 20) });
}
}
// 3) Weapon, muzzle flash and crosshair.
if (player.flash > 0) drawCircle({ pos: vec2(W / 2, H - 70), radius: 26, color: rgb(255, 220, 120) });
drawRect({ pos: vec2(W / 2 - 14, H - 64), width: 28, height: 64, color: rgb(60, 60, 70) });
drawLine({ p1: vec2(W / 2 - 8, H / 2), p2: vec2(W / 2 + 8, H / 2), width: 2, color: rgb(255, 255, 255) });
drawLine({ p1: vec2(W / 2, H / 2 - 8), p2: vec2(W / 2, H / 2 + 8), width: 2, color: rgb(255, 255, 255) });
if (player.hurt > 0) drawRect({ pos: vec2(0, 0), width: W, height: H, color: rgb(255, 0, 0), opacity: 0.25 });
drawMinimap(enemies);
},
},
]);
// The minimap shows the truth behind the illusion: a flat grid, and a fan of rays.
function drawMinimap(enemies) {
const S = 7;
const o = vec2(W - MAP[0].length * S - 10, 10);
for (let y = 0; y < MAP.length; y++)
for (let x = 0; x < MAP[y].length; x++)
if (isWall(x, y)) drawRect({ pos: o.add(x * S, y * S), width: S, height: S, color: rgb(...WALL_COLOR[MAP[y][x]]), opacity: 0.7 });
const p = o.add(player.pos.scale(S));
for (let i = 0; i < RAYS; i += 20) {
const camX = (2 * (i + 0.5)) / RAYS - 1;
const a = player.angle + Math.atan(camX * Math.tan(FOV / 2));
const len = depth[i] / Math.cos(a - player.angle);
drawLine({ p1: p, p2: p.add(Math.cos(a) * len * S, Math.sin(a) * len * S), width: 1, color: rgb(255, 230, 120), opacity: 0.6 });
}
for (const e of enemies) drawCircle({ pos: o.add(e.pos.scale(S)), radius: 2, color: rgb(230, 70, 70) });
drawCircle({ pos: p, radius: 2.5, color: rgb(255, 255, 255) });
}
// #endregion
// #region hud
const hud = add([text("", { size: 16, font: FONT }), pos(12, 10), z(10)]);
hud.onUpdate(() => {
hud.text = t({ es: "Salud ", en: "Health " }) + Math.max(0, Math.ceil(player.health)) + " " + t({ es: "Enemigos ", en: "Enemies " }) + get("enemy").length;
});
// #endregion
});
// #region scenes
function screen(title, subtitle) {
add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 40)), anchor("center")]);
add([text(subtitle, { size: 16, font: FONT, align: "center", width: 560 }), pos(center().add(0, 30)), anchor("center"), color(200, 210, 255)]);
onButtonPress("start", () => go("game"));
onButtonPress("fire", () => go("game"));
onMousePress(() => go("game"));
}
scene("title", () =>
screen(
t({ es: "Disparos en primera persona", en: "First-person shooter" }),
t({
es: "Flechas o WASD para girar y avanzar, espacio o clic para disparar.\nEncuentra la puerta verde de salida.\nPulsa espacio o toca para empezar.",
en: "Arrows or WASD to turn and move, space or click to shoot.\nFind the green exit door.\nPress space or tap to start.",
}),
),
);
scene("end", (won) =>
screen(
won ? t({ es: "¡Saliste!", en: "You got out!" }) : t({ es: "Te alcanzaron", en: "They got you" }),
t({ es: "Pulsa espacio o toca para jugar otra vez.", en: "Press space or tap to play again." }),
),
);
go("title");
// #endregionDesign challenges
- Drawing the 3D world fast enough. Wolfenstein 3D solved it with raycasting over a flat map and later engines with techniques to discard what cannot be seen; with fewer frames, aiming becomes clumsy.
- Making shooting feel good, with recoil, sound, muzzle flash, a visible enemy reaction and hits that read instantly.
- Helping the player find their way while seeing only what is in front of them, with levels recognisable by shapes and colours instead of relying on the map.
- Enemies that use the space (taking cover, flanking, signalling before attacking) without becoming unfair to a player who cannot see them coming.
- Aiming with a gamepad is harder than with a mouse; console games add aim assist and tune the turning speed.
History
- 1973OriginMaze War
The genre's origins go back to Maze War (1973) and Spasim (1974), with first-person multiplayer shooting and wireframe graphics.
First-person shooter (Wikipedia) - 1991
id Software's Hovertank uses ray casting to draw 3D faster and achieve smoother gameplay.
First-person shooter (Wikipedia) - 1992Wolfenstein 3D
Wolfenstein 3D lays the foundations of the modern FPS with ray casting, which works out only the visible walls. Its first episode is distributed free as shareware.
Wolfenstein 3D (Wikipedia) - 1993Doom
Doom adds better graphics and multiplayer deathmatch, and stores its levels in WAD files that fans can modify. For years the genre is called «Doom clones».
Doom (1993 video game) (Wikipedia) - 1996Quake
Quake moves to full real-time 3D with polygonal models, and its QuakeWorld version makes online play one of the first forms of electronic sport.
Quake (video game) (Wikipedia) - 1997
GoldenEye 007, for the Nintendo 64, proves the genre works on consoles and popularises split-screen deathmatch for up to four players.
GoldenEye 007 (1997 video game) (Wikipedia) - 1998Half-Life
Half-Life tells its story through scripted sequences without ever taking camera control away from you, and fan modifications of it give rise to Counter-Strike.
Half-Life (video game) (Wikipedia) - 2001
Halo, for the Xbox, adapts aiming to the gamepad, limits you to two weapons and adds a self-recharging shield, ideas that many console games will copy.
Halo: Combat Evolved (Wikipedia) - 2021Q1K3
For the js13kGames competition, Q1K3 packs into 13 kilobytes of JavaScript an FPS with two levels, five enemy types and three weapons, played in the browser.
Q1K3 (GitHub, PhobosLab)
Reference games
1973
Maze War
You roam a line-drawn maze in first person and shoot the other players, who in later versions appear as a large eyeball. Three high-school students made it at NASA's Ames Research Center, and it came to be played between universities over the ARPANET.
1992
Wolfenstein 3D
You escape a fortress through maze-like corridors, shooting the guards. Its walls are drawn with ray casting over a flat grid map, the same technique as the demo, and its first episode was distributed free as shareware.
1993
Doom
You blast your way through a base full of demons, looking for coloured keys to open doors. It popularised deathmatch and kept its levels in WAD files that fans could edit. Its first episode was shareware.
1996
Quake
A first-person shooter with truly polygonal 3D levels and enemies. Its online multiplayer, improved by QuakeWorld, was one of the first forms of electronic sport.
1998
Half-Life
A scientist escapes an underground laboratory after an accident. The story is told through scripted sequences while you keep control of the camera, with no cutscenes, and fan modifications of it gave rise to Counter-Strike.
2021
Q1K3
A tribute to Quake (1996) in about 13 kilobytes of JavaScript, made for the 2021 js13kGames competition. You move with the keyboard, aim with the mouse and fight through two levels based on Quake's first episode.
Exercise
In the demo, remove the fisheye correction (the Math.cos in the render) and watch the walls bend. Then change FOV and RAYS and compare the image and the smoothness. Finally, redraw the text map so the player can find the way without the minimap, using only wall shapes and colours.
Subgenres
- Arena shooterFrantic multiplayer matches on small, enclosed maps where everyone starts equal and the winners are those who move fastest, control the map's weapons and aim best.
- Boomer shooterYou race through maze-like levels packed with monsters, carrying a whole arsenal at once, hunting for keys and secrets and picking up health and armour off the floor. It is a new FPS that deliberately imitates those of the 1990s, such as Doom or Quake: no slow reloads or cover, it all comes down to moving and aiming fast.