
Boomer shooter
You 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.
Also known as: retro shooter, retro FPS, old-school shooter
This is a basic entry: it has the definition and the classification. Full entries add history, games and a demo.
Our demo
Controls. Click to capture the mouse and look around, WASD or arrows to move and strafe, click or space to shoot. On mobile, drag to look and use the on-screen buttons.
Open full screenA first-person maze with monsters, a key, a locked door and the exit. You move fast, shoot without reloading and pick health up from the floor, like in the FPS games of the nineties. It is made with threeplay, a 3D engine in the spirit of Kaplay.
Made with threeplay, in about 155 lines of JavaScript.
How it's made
Setup and controls
The game is created at 640×360 with virtual buttons to move and shoot; shooting responds to both mouse and keyboard. Speed is high on purpose, because moving fast is half of the genre.
const k = threeplay({ width: 640, height: 360, letterbox: true, background: "#2b1d1d", buttons: { left: { keyboard: ["left", "a"] }, right: { keyboard: ["right", "d"] }, up: { keyboard: ["up", "w"] }, down: { keyboard: ["down", "s"] }, shoot: { keyboard: ["space", "j"], mouse: "left" }, }, }); touchControls(k, [ { button: "left", label: "◀" }, { button: "up", label: "▲" }, { button: "down", label: "▼" }, { button: "right", label: "▶" }, { button: "shoot", label: "✹", side: "right" }, ]); const SPEED = 9; // fast on purpose: speed is half of the genre const TILE = 3;The maze as text
The level is a map of characters and addLevel() turns it into objects on the ground, one 3 m tile per character. That is how nineties levels were designed, with keys that make you cover the map and doors that close it off. Fog hides what is far away and sets the mood.
// "#" wall, "@" start, "e" enemy, "+" health, "k" key, "D" locked door, "x" exit. const MAP = [ "#############", "#@ # e #", "# # ## #", "# e #k #", "### #### ##", "# + e #", "# ###### ###", "#e # x # #", "# # D e #", "# ####### #", "# e #", "#############", ]; function buildLevel() { let start = vec3(); addLevel(MAP, { tileSize: TILE, tiles: { "#": (p) => [box(TILE, 3.5, TILE), pos(p.x, 1.75, p.z), color("#7f5539"), area(), body({ isStatic: true })], "@": (p) => { start = p.clone(); return []; }, e: (p) => [box(1, 1.6, 1), pos(p.x, 0.8, p.z), rotate(), color("#c1121f"), area(), character(), health(3), "enemy"], "+": (p) => [box(0.6, 0.6, 0.6), pos(p.x, 0.5, p.z), color("#52b788"), area(), "medkit"], k: (p) => [box(0.5, 0.5, 0.15), pos(p.x, 1, p.z), rotate(), color("gold"), area(), "key"], D: (p) => [box(TILE, 3.5, 0.5), pos(p.x, 1.75, p.z), rotate(0, 90, 0), color("#ffd60a"), area(), body({ kinematic: true }), "door"], x: (p) => [cylinder(1, 0.1), pos(p.x, 0.05, p.z), color("#00f5d4"), area(), "exit"], }, }); const w = MAP[0].length * TILE; const d = MAP.length * TILE; add([box(w, 1, d), pos(0, -0.5, 0), color("#3a3a3a"), area(), body({ isStatic: true })]); setFog({ color: "#2b1d1d", near: 4, far: 30 }); return start; }Running and strafing
firstPerson() puts the camera in the player's eyes and turns it with the mouse. Since inputDir() is relative to the camera, A and D step sideways without turning, the move that lets you dodge while shooting.
const player = add([capsule(0.4, 1.6), pos(start.x, 1, start.z), rotate(), area(), character(), health(10), "player"]); player.hidden = true; // we look out of its eyes firstPerson(player, { height: 0.6, lockOnClick: !isTouchscreen() }); onUpdate(() => { // Strafing: inputDir is relative to where the camera looks, so A/D step sideways. player.move(inputDir().multiplyScalar(SPEED)); });Hitscan shooting
The shot is a ray from the eye towards where the camera looks, with raycast(). Whatever it touches first takes the damage at once, with no projectile. The weapon and the muzzle flash are on-screen rectangles animated with a tween.
// Hitscan: the shot is a ray from the eye; whatever it hits first takes the damage. const gun = add([uiRect(36, 70, { radius: 4 }), pos(320, 372), anchor("bot"), color("#495057"), fixed()]); const flash = add([uiRect(30, 30, { radius: 15 }), pos(320, 296), anchor("center"), color("#ffd166"), fixed()]); flash.hidden = true; onButtonPress("shoot", () => { flash.hidden = false; wait(0.05, () => (flash.hidden = true)); gun.pos.y = 380; tween(380, 372, 0.12, (y) => (gun.pos.y = y)); beep({ freq: 180, slideTo: 60, duration: 0.1, type: "sawtooth" }); const hit = raycast(camPos(), camera3d.getWorldDirection(vec3()), 60, { exclude: [player] }); if (hit?.obj.is("enemy")) hit.obj.hurt(1); });Monsters that see you
Each enemy casts another ray towards the player and only chases if there is no wall in between. It has health(3), and the hurt and death events drive the white flash and its removal. Touching you takes health, with a moment of invulnerability so it doesn't pile up in a single frame.
// An enemy chases only when it can see you: a ray to the player must not hit a wall first. function canSee(enemy) { const from = enemy.worldPos().add(vec3(0, 0.5, 0)); const dir = player.worldPos().sub(from); const hit = raycast(from, dir, 20, { exclude: [enemy] }); return hit?.obj === player; } onUpdate("enemy", (e) => { if (e.distanceTo(player) < 20 && canSee(e)) { e.moveTo(player, 3.5); e.lookAt(vec3(player.pos.x, e.pos.y, player.pos.z)); } }); on("hurt", "enemy", (e) => { e.color.set("white"); wait(0.06, () => e.exists() && e.color.set("#c1121f")); }); on("death", "enemy", (e) => { kills++; destroy(e); updateHud(); }); let invulnerable = false; player.onCollideUpdate("enemy", () => { if (invulnerable) return; invulnerable = true; player.hurt(1); camShake(0.15, 0.25); updateHud(); wait(0.8, () => (invulnerable = false)); }); player.onDeath(() => go("end", false));Key, door and medkits
The floor items are sensors. The medkit heals, the key is noted down and the yellow door, a kinematic body, only sinks if you touch it while holding the key. This lock is what turns the maze into a route.
let hasKey = false; player.onCollide("medkit", (m) => { destroy(m); player.heal(3); beep({ freq: 660, slideTo: 990, duration: 0.15 }); updateHud(); }); player.onCollide("key", (key) => { destroy(key); hasKey = true; beep({ freq: 880, slideTo: 1320, duration: 0.2 }); updateHud(); }); player.onCollide("door", (door) => { if (!hasKey || door.is("opening")) return; door.tag("opening"); tween(door.pos.y, -1.8, 1, (y) => (door.pos.y = y)); }); player.onCollide("exit", () => go("end", true));The counter
A line with health, kills and whether you have the key, plus a crosshair in the middle of the screen.
let kills = 0; const total = get("enemy").length; const hud = add([text("", { size: 16, font: FONT }), pos(12, 10), fixed()]); add([text("+", { size: 22, font: FONT }), pos(320, 180), anchor("center"), fixed()]); function updateHud() { const key = hasKey ? t({ es: " · llave ✓", en: " · key ✓" }) : ""; hud.text = `${t({ es: "Vida", en: "Health" })} ${player.hp} ${t({ es: "Bajas", en: "Kills" })} ${kills}/${total}${key}`; } updateHud();Scenes
Title, game and ending. When it ends, unlockPointer() releases the mouse so you can use the page again.
function screen(title, subtitle) { add([text(title, { size: 38, font: FONT }), pos(320, 140), anchor("center"), fixed()]); add([text(subtitle, { size: 15, font: FONT }), pos(320, 190), anchor("center"), fixed()]); add([text(t({ es: "Clic o espacio para jugar", en: "Click or space to play" }), { size: 15, font: FONT }), pos(320, 240), anchor("center"), fixed()]); onButtonPress("shoot", () => go("game")); } scene("title", () => { screen("Boomer shooter", t({ es: "Encuentra la llave, abre la puerta amarilla y llega a la salida", en: "Find the key, open the yellow door and reach the exit" })); }); scene("end", (won) => { unlockPointer(); screen(won ? t({ es: "¡Saliste!", en: "You made it out!" }) : t({ es: "Has caído", en: "You died" }), ""); }); go("title");
What we simplified
- There is a single weapon and a single enemy type; the genre thrives on whole arsenals and monsters with different behaviours.
- Enemies only chase and push; they don't shoot or dodge.
- No armour, ammo or secrets, and the weapon is a rectangle on screen.
To modify it
- Add a second weapon, a shotgun that casts five rays with some spread.
- Make one enemy type fire slow projectiles that you can dodge by strafing.
- Hide a secret behind a fake wall that opens when you touch it.
Full code
import threeplay from "../../vendor/threeplay/threeplay.mjs";
import { FONT, t, touchControls } from "../common.js";
// #region setup
const k = threeplay({
width: 640,
height: 360,
letterbox: true,
background: "#2b1d1d",
buttons: {
left: { keyboard: ["left", "a"] },
right: { keyboard: ["right", "d"] },
up: { keyboard: ["up", "w"] },
down: { keyboard: ["down", "s"] },
shoot: { keyboard: ["space", "j"], mouse: "left" },
},
});
touchControls(k, [
{ button: "left", label: "◀" },
{ button: "up", label: "▲" },
{ button: "down", label: "▼" },
{ button: "right", label: "▶" },
{ button: "shoot", label: "✹", side: "right" },
]);
const SPEED = 9; // fast on purpose: speed is half of the genre
const TILE = 3;
// #endregion
// #region level
// "#" wall, "@" start, "e" enemy, "+" health, "k" key, "D" locked door, "x" exit.
const MAP = [
"#############",
"#@ # e #",
"# # ## #",
"# e #k #",
"### #### ##",
"# + e #",
"# ###### ###",
"#e # x # #",
"# # D e #",
"# ####### #",
"# e #",
"#############",
];
function buildLevel() {
let start = vec3();
addLevel(MAP, {
tileSize: TILE,
tiles: {
"#": (p) => [box(TILE, 3.5, TILE), pos(p.x, 1.75, p.z), color("#7f5539"), area(), body({ isStatic: true })],
"@": (p) => {
start = p.clone();
return [];
},
e: (p) => [box(1, 1.6, 1), pos(p.x, 0.8, p.z), rotate(), color("#c1121f"), area(), character(), health(3), "enemy"],
"+": (p) => [box(0.6, 0.6, 0.6), pos(p.x, 0.5, p.z), color("#52b788"), area(), "medkit"],
k: (p) => [box(0.5, 0.5, 0.15), pos(p.x, 1, p.z), rotate(), color("gold"), area(), "key"],
D: (p) => [box(TILE, 3.5, 0.5), pos(p.x, 1.75, p.z), rotate(0, 90, 0), color("#ffd60a"), area(), body({ kinematic: true }), "door"],
x: (p) => [cylinder(1, 0.1), pos(p.x, 0.05, p.z), color("#00f5d4"), area(), "exit"],
},
});
const w = MAP[0].length * TILE;
const d = MAP.length * TILE;
add([box(w, 1, d), pos(0, -0.5, 0), color("#3a3a3a"), area(), body({ isStatic: true })]);
setFog({ color: "#2b1d1d", near: 4, far: 30 });
return start;
}
// #endregion
scene("game", () => {
const start = buildLevel();
// #region player
const player = add([capsule(0.4, 1.6), pos(start.x, 1, start.z), rotate(), area(), character(), health(10), "player"]);
player.hidden = true; // we look out of its eyes
firstPerson(player, { height: 0.6, lockOnClick: !isTouchscreen() });
onUpdate(() => {
// Strafing: inputDir is relative to where the camera looks, so A/D step sideways.
player.move(inputDir().multiplyScalar(SPEED));
});
// #endregion
// #region shooting
// Hitscan: the shot is a ray from the eye; whatever it hits first takes the damage.
const gun = add([uiRect(36, 70, { radius: 4 }), pos(320, 372), anchor("bot"), color("#495057"), fixed()]);
const flash = add([uiRect(30, 30, { radius: 15 }), pos(320, 296), anchor("center"), color("#ffd166"), fixed()]);
flash.hidden = true;
onButtonPress("shoot", () => {
flash.hidden = false;
wait(0.05, () => (flash.hidden = true));
gun.pos.y = 380;
tween(380, 372, 0.12, (y) => (gun.pos.y = y));
beep({ freq: 180, slideTo: 60, duration: 0.1, type: "sawtooth" });
const hit = raycast(camPos(), camera3d.getWorldDirection(vec3()), 60, { exclude: [player] });
if (hit?.obj.is("enemy")) hit.obj.hurt(1);
});
// #endregion
// #region enemies
// An enemy chases only when it can see you: a ray to the player must not hit a wall first.
function canSee(enemy) {
const from = enemy.worldPos().add(vec3(0, 0.5, 0));
const dir = player.worldPos().sub(from);
const hit = raycast(from, dir, 20, { exclude: [enemy] });
return hit?.obj === player;
}
onUpdate("enemy", (e) => {
if (e.distanceTo(player) < 20 && canSee(e)) {
e.moveTo(player, 3.5);
e.lookAt(vec3(player.pos.x, e.pos.y, player.pos.z));
}
});
on("hurt", "enemy", (e) => {
e.color.set("white");
wait(0.06, () => e.exists() && e.color.set("#c1121f"));
});
on("death", "enemy", (e) => {
kills++;
destroy(e);
updateHud();
});
let invulnerable = false;
player.onCollideUpdate("enemy", () => {
if (invulnerable) return;
invulnerable = true;
player.hurt(1);
camShake(0.15, 0.25);
updateHud();
wait(0.8, () => (invulnerable = false));
});
player.onDeath(() => go("end", false));
// #endregion
// #region pickups
let hasKey = false;
player.onCollide("medkit", (m) => {
destroy(m);
player.heal(3);
beep({ freq: 660, slideTo: 990, duration: 0.15 });
updateHud();
});
player.onCollide("key", (key) => {
destroy(key);
hasKey = true;
beep({ freq: 880, slideTo: 1320, duration: 0.2 });
updateHud();
});
player.onCollide("door", (door) => {
if (!hasKey || door.is("opening")) return;
door.tag("opening");
tween(door.pos.y, -1.8, 1, (y) => (door.pos.y = y));
});
player.onCollide("exit", () => go("end", true));
// #endregion
// #region hud
let kills = 0;
const total = get("enemy").length;
const hud = add([text("", { size: 16, font: FONT }), pos(12, 10), fixed()]);
add([text("+", { size: 22, font: FONT }), pos(320, 180), anchor("center"), fixed()]);
function updateHud() {
const key = hasKey ? t({ es: " · llave ✓", en: " · key ✓" }) : "";
hud.text = `${t({ es: "Vida", en: "Health" })} ${player.hp} ${t({ es: "Bajas", en: "Kills" })} ${kills}/${total}${key}`;
}
updateHud();
// #endregion
});
// #region scenes
function screen(title, subtitle) {
add([text(title, { size: 38, font: FONT }), pos(320, 140), anchor("center"), fixed()]);
add([text(subtitle, { size: 15, font: FONT }), pos(320, 190), anchor("center"), fixed()]);
add([text(t({ es: "Clic o espacio para jugar", en: "Click or space to play" }), { size: 15, font: FONT }), pos(320, 240), anchor("center"), fixed()]);
onButtonPress("shoot", () => go("game"));
}
scene("title", () => {
screen("Boomer shooter", t({ es: "Encuentra la llave, abre la puerta amarilla y llega a la salida", en: "Find the key, open the yellow door and reach the exit" }));
});
scene("end", (won) => {
unlockPointer();
screen(won ? t({ es: "¡Saliste!", en: "You made it out!" }) : t({ es: "Has caído", en: "You died" }), "");
});
go("title");
// #endregionHistory
- 2018OriginDusk
According to New Blood Interactive's Dave Oshry, fans coined the term after Dusk (2018) came out. "Boomer" is a joking nod to anything old-fashioned.
Shooter game (Wikipedia) - 2020Ultrakill
Ultrakill enters early access, adding fast modern movement and an action-game style to the 1990s template.
Ultrakill (Wikipedia)
Reference games
2018
Dusk
A dark-toned FPS that imitates the graphics and pace of 1990s shooters. After its release, its fans coined the term 'boomer shooter'.
2020
Ultrakill
An FPS with early-PlayStation-style graphics that adds the fast movement of modern shooters and elements of action games such as Devil May Cry. It has been in early access since 2020.
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.