
Light-gun shooter
You aim a gun-shaped controller at the screen and shoot targets as they appear; the challenge is reacting quickly and hitting them before they hit you.
Also known as: light gun game, light gun shooter, shooting gallery
Defining mechanics
- Aiming straight at the screen, so the sight is wherever you point. Today the mouse, the touch screen or motion-sensing controllers play that role.
- Targets that pop out and hide again, with a short window to hit them before they fire.
- Limited ammo and reloading, done by shooting off-screen or by taking cover.
- Civilians or hostages you must not shoot, which force you to look before you pull the trigger.
- A fixed path on which the game moves the camera while you only aim, and extra points for precision.
Gameplay loop
A target pops out, you decide whether it is an enemy or a civilian, aim and fire before it hits you, reload when things are calm and the scene moves on.
Our demo
Controls. The mouse or your finger is the gun; click or tap to fire. Reload with right-click, with R, by shooting the black frame around the screen or, on mobile, with the reload button.
Open full screenA shooting gallery with four covers behind which cardboard figures pop up. The red ones shoot you if you are slow and the green ones are civilians. Just enough to feel what defines the genre, which is aiming quickly at the screen, telling who to shoot and deciding when to reload.
Made with Kaplay, in about 195 lines of JavaScript.
How it's made
Setup and controls
The mouse or your finger acts as the gun, so virtual buttons are barely needed; there is only one for reloading on mobile. SCREEN is the rectangle that plays the television, and shooting the frame around it is like aiming off-screen, which is how you reloaded with a real light gun. The six-shot magazine and the reload time are the numbers that most change the tension.
const k = kaplay({ width: 640, height: 360, letterbox: true, pixelDensity, background: [16, 18, 30], buttons: { reload: { keyboard: ["r"] }, start: { keyboard: ["space", "enter"] }, }, }); touchControls(k, [{ button: "reload", label: "⟳", side: "right" }]); // The mouse (or your finger) is the gun. The inner rectangle is the "screen": // shooting at the frame around it is like pointing the gun away to reload. const SCREEN = { x: 28, y: 28, w: 584, h: 304 }; const GROUND = 262; // y where the floor starts const MAGAZINE = 6; const RELOAD_TIME = 0.4; const RED = rgb(220, 70, 60); const GREEN = rgb(90, 190, 120);The stage and the covers
The covers are rectangles drawn in front of the figures, so whatever is behind them is hidden without cropping anything. A shot that lands on a cover stops there. The black frame is drawn on top of everything so nothing shows outside the television.
// Covers are drawn in front of the targets, so a target behind one is hidden // and a shot that lands on a cover stops there. const COVERS = [ { x: 60, y: 222, w: 130, h: 110 }, { x: 215, y: 140, w: 110, h: 96 }, { x: 350, y: 236, w: 120, h: 96 }, { x: 482, y: 160, w: 110, h: 96 }, ]; function buildStage() { add([rect(SCREEN.w, SCREEN.h), pos(SCREEN.x, SCREEN.y), color(52, 58, 84)]); add([rect(SCREEN.w, SCREEN.y + SCREEN.h - GROUND), pos(SCREEN.x, GROUND), color(70, 62, 56)]); for (const c of COVERS) { // A post under the high covers, so they stand on the ground. if (c.y + c.h < GROUND) add([rect(20, GROUND - c.y - c.h), pos(c.x + c.w / 2 - 10, c.y + c.h), color(90, 64, 40), z(1)]); add([rect(c.w, c.h), pos(c.x, c.y), color(150, 110, 70), outline(3, rgb(90, 64, 40)), z(5)]); add([rect(c.w - 16, 6), pos(c.x + 8, c.y + 14), color(120, 86, 54), z(5)]); } // The frame: everything outside SCREEN. It is drawn on top of the whole stage. const frame = rgb(10, 10, 16); add([rect(640, SCREEN.y), color(frame), z(15)]); add([rect(640, 360 - SCREEN.y - SCREEN.h), pos(0, SCREEN.y + SCREEN.h), color(frame), z(15)]); add([rect(SCREEN.x, 360), color(frame), z(15)]); add([rect(640 - SCREEN.x - SCREEN.w, 360), pos(SCREEN.x + SCREEN.w, 0), color(frame), z(15)]); } const inside = (p, r) => p.x >= r.x && p.x <= r.x + r.w && p.y >= r.y && p.y <= r.y + r.h;Pop-up figures
Each figure goes through three states: it rises from behind its cover, waits in view and goes back down. Enemies are red and carry a bullseye; civilians are green with their hands up, because in this genre you must tell them apart at a glance by colour and silhouette. The yellow bar above the head is the countdown: if it reaches zero, the figure fires and you lose a life. Hits are checked by hand, looking at whether the point falls on the head or the body and not on a cover.
// A target is a cardboard figure that rises from behind a cover, waits and hides again. // Enemies (red, with a bullseye) shoot you if you are too slow; civilians (green, // hands up) must not be shot. function popUp(coverIndex, kind, window) { const c = COVERS[coverIndex]; const hiddenY = c.y + 6; const shownY = c.y - 64; const target = add([pos(c.x + c.w / 2, hiddenY), z(2), "target", { kind, state: "rising", timer: window, window, cover: coverIndex, draw() { const col = kind === "enemy" ? RED : GREEN; drawRect({ pos: vec2(-17, 26), width: 34, height: 52, color: col, outline: { width: 2, color: WHITE } }); drawCircle({ pos: vec2(0, 13), radius: 13, color: col, outline: { width: 2, color: WHITE } }); if (kind === "enemy") { for (const [r, c2] of [[16, WHITE], [10, RED], [5, WHITE]]) drawCircle({ pos: vec2(0, 48), radius: r, color: c2 }); // The bar above the head is the time you have left before it shoots. drawRect({ pos: vec2(-17, -10), width: (34 * this.timer) / this.window, height: 4, color: rgb(255, 220, 80) }); } else { drawRect({ pos: vec2(-24, -2), width: 6, height: 30, color: GREEN, outline: { width: 2, color: WHITE } }); drawRect({ pos: vec2(18, -2), width: 6, height: 30, color: GREEN, outline: { width: 2, color: WHITE } }); } }, }]); target.onUpdate(() => { if (target.state === "rising") { target.pos.y = Math.max(shownY, target.pos.y - 500 * dt()); if (target.pos.y === shownY) target.state = "up"; } else if (target.state === "up") { target.timer -= dt(); if (target.timer <= 0) { if (kind === "enemy") target.trigger("fire"); target.state = "hiding"; } } else { target.pos.y += 500 * dt(); if (target.pos.y >= hiddenY) destroy(target); } }); return target; } // Hit test by hand: the shot must land on the head or the body, and not on a cover. function hitTarget(p) { if (COVERS.some((c) => inside(p, c))) return null; return get("target").find((g) => { if (g.state === "hiding") return false; const head = p.dist(g.pos.add(0, 13)) <= 13; const body = inside(p, { x: g.pos.x - 17, y: g.pos.y + 26, w: 34, h: 52 }); return head || body; }); }The round's script
The round is a text. Each word says who pops up (E, enemy; C, civilian) and behind which cover (0 to 3), and words joined with + appear together. It is the same trick as the platformer's text map. Each step comes a little sooner and figures stay in view for less time, so the round speeds up by itself. If a step's cover is still busy, the step waits, because there is only one figure behind each cover at a time.
// The round is a text: E = enemy, C = civilian, digit = cover. Tokens joined with + // pop up at the same time. Each step comes a little sooner and stays a little less. const SCRIPT = "E0 E2 E1 C3 E3 E0 E2+E1 C0 E3 E1+C2 E0 E2 E3+E1 C1 E0+E2 E3 C0+E1 E2 E3+E0 E1+E2"; const steps = SCRIPT.split(" ").map((s) => s.split("+")); let step = 0; let next = 1.2; onUpdate(() => { next -= dt(); if (next > 0 || step >= steps.length) return; // One figure per cover at a time: if a cover is busy, the step waits. if (steps[step].some((tok) => get("target").some((g) => g.cover === Number(tok[1])))) return; const window = Math.max(1.3, 2.6 - step * 0.07); for (const tok of steps[step]) { const g = popUp(Number(tok[1]), tok[0] === "E" ? "enemy" : "civilian", window); g.on("fire", () => loseLife()); } step++; next = Math.max(0.9, 1.8 - step * 0.05); });Shooting with precision
A click inside the screen uses a bullet; one on the frame reloads. If you hit an enemy, the points depend on the distance to the centre of the bullseye (100, 50 or 25), and the score keeps track of your accuracy, hits divided by shots. Shooting a civilian takes points away and costs a life.
// Precision pays: the closer to the centre of the bullseye, the more points. function fire(p) { if (reloading > 0) return; if (ammo === 0) return shake(2); ammo--; stats.shots++; add([circle(5), pos(p), color(255, 240, 160), opacity(1), lifespan(0.12, { fade: 0.1 }), z(18)]); const g = hitTarget(p); if (!g) return; g.state = "hiding"; if (g.kind === "civilian") { stats.civilians++; stats.score = Math.max(0, stats.score - 200); return loseLife(); } stats.hits++; const d = p.dist(g.pos.add(0, 48)); const points = d < 5 ? 100 : d < 10 ? 50 : 25; stats.score += points; add([text(`+${points}`, { size: 16, font: FONT }), pos(p.sub(0, 16)), anchor("center"), opacity(1), lifespan(0.5, { fade: 0.3 }), move(UP, 40), z(18)]); } onMousePress("left", () => { const p = mousePos(); if (inside(p, SCREEN)) fire(p); else reload(); // shooting off-screen reloads, as with a real light gun });Reloading as a decision
With six bullets you cannot fire without thinking. Reloading takes a moment during which you cannot shoot, so it is best done in a calm moment and not with an enemy in view. When the magazine runs empty, a blinking warning reminds you.
// With six shots, reloading is a decision: when is it safe to look away? function reload() { if (reloading > 0 || ammo === MAGAZINE) return; reloading = RELOAD_TIME; } onMousePress("right", reload); onButtonPress("reload", reload); onUpdate(() => { if (reloading > 0) { reloading -= dt(); if (reloading <= 0) ammo = MAGAZINE; } warning.text = reloading > 0 ? "…" : ammo === 0 && Math.floor(time() * 3) % 2 ? t({ es: "¡RECARGA!", en: "RELOAD!" }) : ""; });Lives, score and crosshair
You lose a life if an enemy gets to fire or if you hit a civilian; the screen shakes and flashes red so you notice. The round is cleared when the script ends and you still have a life. The crosshair is drawn last, on top of everything, and the system cursor is hidden during play.
function loseLife() { stats.lives--; shake(10); flash(RED, 0.3); if (stats.lives <= 0) go("end", false, stats); } onUpdate(() => { const acc = stats.shots ? Math.round((100 * stats.hits) / stats.shots) : 100; hud.text = t({ es: `Puntos ${stats.score} Vidas ${"♥".repeat(stats.lives)} Precisión ${acc}% Balas ${"▮".repeat(ammo)}`, en: `Score ${stats.score} Lives ${"♥".repeat(stats.lives)} Accuracy ${acc}% Ammo ${"▮".repeat(ammo)}`, }); if (step >= steps.length && get("target").length === 0) go("end", true, stats); }); // The crosshair is drawn last, on top of everything. add([z(30), { draw() { const m = mousePos(); drawCircle({ pos: m, radius: 10, fill: false, outline: { width: 2, color: WHITE } }); drawCircle({ pos: m, radius: 2, color: WHITE }); } }]);Scenes
There are three scenes (title, game and end) and go() moves between them. The end screen shows score, accuracy and civilians hit, the three things the genre measures.
function screen(title, subtitle, next) { setCursor("default"); add([text(title, { size: 32, font: FONT, align: "center" }), pos(center().sub(0, 70)), anchor("center")]); add([ text(subtitle, { size: 15, font: FONT, align: "center", width: 580 }), pos(center().add(0, 30)), anchor("center"), color(200, 210, 255), ]); onButtonPress("start", next); onClick(next); } scene("title", () => screen( t({ es: "Galería de tiro", en: "Shooting gallery" }), t({ es: "Apunta con el ratón o el dedo y dispara a las figuras rojas antes de que te disparen.\nNo dispares a las verdes, que tienen las manos en alto.\nTienes seis balas: recarga con clic derecho, con R o disparando al marco negro.\nCuanto más cerca del centro de la diana, más puntos.\nHaz clic o pulsa espacio para empezar.", en: "Aim with the mouse or your finger and shoot the red figures before they shoot you.\nDo not shoot the green ones with their hands up.\nYou have six shots: reload with right-click, with R or by shooting the black frame.\nThe closer to the centre of the bullseye, the more points.\nClick or press space to start.", }), () => go("game"), ), ); scene("end", (won, s) => { const acc = s.shots ? Math.round((100 * s.hits) / s.shots) : 0; screen( won ? t({ es: "¡Ronda superada!", en: "Round cleared!" }) : t({ es: "Fin de la partida", en: "Game over" }), t({ es: `Puntos: ${s.score} Precisión: ${acc}% Civiles alcanzados: ${s.civilians}\nHaz clic o pulsa espacio para repetir.`, en: `Score: ${s.score} Accuracy: ${acc}% Civilians hit: ${s.civilians}\nClick or press space to play again.`, }), () => go("game"), ); }); go("title");
What we simplified
- The camera does not move; the scene is a single gallery, without the on-rails path of many games in the genre.
- Enemies do not fire visible bullets; when their time runs out you simply lose a life.
- There is no cover for the player (like Time Crisis's pedal) and no head or hand shots.
To modify it
- Add cover for the player. While you hold a key you cannot be shot or shoot, and you reload automatically, as with Time Crisis's pedal.
- Make figures also come out from the side of a cover, sliding horizontally.
- Create a figure that needs two shots and think about how to show that it has already taken one.
Full code
// Light-gun shooter demo
// 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: [16, 18, 30],
buttons: {
reload: { keyboard: ["r"] },
start: { keyboard: ["space", "enter"] },
},
});
touchControls(k, [{ button: "reload", label: "⟳", side: "right" }]);
// The mouse (or your finger) is the gun. The inner rectangle is the "screen":
// shooting at the frame around it is like pointing the gun away to reload.
const SCREEN = { x: 28, y: 28, w: 584, h: 304 };
const GROUND = 262; // y where the floor starts
const MAGAZINE = 6;
const RELOAD_TIME = 0.4;
const RED = rgb(220, 70, 60);
const GREEN = rgb(90, 190, 120);
// #endregion
// #region stage
// Covers are drawn in front of the targets, so a target behind one is hidden
// and a shot that lands on a cover stops there.
const COVERS = [
{ x: 60, y: 222, w: 130, h: 110 },
{ x: 215, y: 140, w: 110, h: 96 },
{ x: 350, y: 236, w: 120, h: 96 },
{ x: 482, y: 160, w: 110, h: 96 },
];
function buildStage() {
add([rect(SCREEN.w, SCREEN.h), pos(SCREEN.x, SCREEN.y), color(52, 58, 84)]);
add([rect(SCREEN.w, SCREEN.y + SCREEN.h - GROUND), pos(SCREEN.x, GROUND), color(70, 62, 56)]);
for (const c of COVERS) {
// A post under the high covers, so they stand on the ground.
if (c.y + c.h < GROUND) add([rect(20, GROUND - c.y - c.h), pos(c.x + c.w / 2 - 10, c.y + c.h), color(90, 64, 40), z(1)]);
add([rect(c.w, c.h), pos(c.x, c.y), color(150, 110, 70), outline(3, rgb(90, 64, 40)), z(5)]);
add([rect(c.w - 16, 6), pos(c.x + 8, c.y + 14), color(120, 86, 54), z(5)]);
}
// The frame: everything outside SCREEN. It is drawn on top of the whole stage.
const frame = rgb(10, 10, 16);
add([rect(640, SCREEN.y), color(frame), z(15)]);
add([rect(640, 360 - SCREEN.y - SCREEN.h), pos(0, SCREEN.y + SCREEN.h), color(frame), z(15)]);
add([rect(SCREEN.x, 360), color(frame), z(15)]);
add([rect(640 - SCREEN.x - SCREEN.w, 360), pos(SCREEN.x + SCREEN.w, 0), color(frame), z(15)]);
}
const inside = (p, r) => p.x >= r.x && p.x <= r.x + r.w && p.y >= r.y && p.y <= r.y + r.h;
// #endregion
// #region targets
// A target is a cardboard figure that rises from behind a cover, waits and hides again.
// Enemies (red, with a bullseye) shoot you if you are too slow; civilians (green,
// hands up) must not be shot.
function popUp(coverIndex, kind, window) {
const c = COVERS[coverIndex];
const hiddenY = c.y + 6;
const shownY = c.y - 64;
const target = add([pos(c.x + c.w / 2, hiddenY), z(2), "target", {
kind, state: "rising", timer: window, window, cover: coverIndex,
draw() {
const col = kind === "enemy" ? RED : GREEN;
drawRect({ pos: vec2(-17, 26), width: 34, height: 52, color: col, outline: { width: 2, color: WHITE } });
drawCircle({ pos: vec2(0, 13), radius: 13, color: col, outline: { width: 2, color: WHITE } });
if (kind === "enemy") {
for (const [r, c2] of [[16, WHITE], [10, RED], [5, WHITE]]) drawCircle({ pos: vec2(0, 48), radius: r, color: c2 });
// The bar above the head is the time you have left before it shoots.
drawRect({ pos: vec2(-17, -10), width: (34 * this.timer) / this.window, height: 4, color: rgb(255, 220, 80) });
} else {
drawRect({ pos: vec2(-24, -2), width: 6, height: 30, color: GREEN, outline: { width: 2, color: WHITE } });
drawRect({ pos: vec2(18, -2), width: 6, height: 30, color: GREEN, outline: { width: 2, color: WHITE } });
}
},
}]);
target.onUpdate(() => {
if (target.state === "rising") {
target.pos.y = Math.max(shownY, target.pos.y - 500 * dt());
if (target.pos.y === shownY) target.state = "up";
} else if (target.state === "up") {
target.timer -= dt();
if (target.timer <= 0) {
if (kind === "enemy") target.trigger("fire");
target.state = "hiding";
}
} else {
target.pos.y += 500 * dt();
if (target.pos.y >= hiddenY) destroy(target);
}
});
return target;
}
// Hit test by hand: the shot must land on the head or the body, and not on a cover.
function hitTarget(p) {
if (COVERS.some((c) => inside(p, c))) return null;
return get("target").find((g) => {
if (g.state === "hiding") return false;
const head = p.dist(g.pos.add(0, 13)) <= 13;
const body = inside(p, { x: g.pos.x - 17, y: g.pos.y + 26, w: 34, h: 52 });
return head || body;
});
}
// #endregion
scene("game", () => {
buildStage();
setCursor("none");
const stats = { score: 0, shots: 0, hits: 0, civilians: 0, lives: 3 };
let ammo = MAGAZINE;
let reloading = 0;
const hud = add([text("", { size: 15, font: FONT }), pos(36, 6), z(20)]);
const warning = add([text("", { size: 26, font: FONT }), pos(320, 190), anchor("center"), color(255, 230, 90), z(20)]);
// #region script
// The round is a text: E = enemy, C = civilian, digit = cover. Tokens joined with +
// pop up at the same time. Each step comes a little sooner and stays a little less.
const SCRIPT = "E0 E2 E1 C3 E3 E0 E2+E1 C0 E3 E1+C2 E0 E2 E3+E1 C1 E0+E2 E3 C0+E1 E2 E3+E0 E1+E2";
const steps = SCRIPT.split(" ").map((s) => s.split("+"));
let step = 0;
let next = 1.2;
onUpdate(() => {
next -= dt();
if (next > 0 || step >= steps.length) return;
// One figure per cover at a time: if a cover is busy, the step waits.
if (steps[step].some((tok) => get("target").some((g) => g.cover === Number(tok[1])))) return;
const window = Math.max(1.3, 2.6 - step * 0.07);
for (const tok of steps[step]) {
const g = popUp(Number(tok[1]), tok[0] === "E" ? "enemy" : "civilian", window);
g.on("fire", () => loseLife());
}
step++;
next = Math.max(0.9, 1.8 - step * 0.05);
});
// #endregion
// #region shooting
// Precision pays: the closer to the centre of the bullseye, the more points.
function fire(p) {
if (reloading > 0) return;
if (ammo === 0) return shake(2);
ammo--;
stats.shots++;
add([circle(5), pos(p), color(255, 240, 160), opacity(1), lifespan(0.12, { fade: 0.1 }), z(18)]);
const g = hitTarget(p);
if (!g) return;
g.state = "hiding";
if (g.kind === "civilian") {
stats.civilians++;
stats.score = Math.max(0, stats.score - 200);
return loseLife();
}
stats.hits++;
const d = p.dist(g.pos.add(0, 48));
const points = d < 5 ? 100 : d < 10 ? 50 : 25;
stats.score += points;
add([text(`+${points}`, { size: 16, font: FONT }), pos(p.sub(0, 16)), anchor("center"), opacity(1), lifespan(0.5, { fade: 0.3 }), move(UP, 40), z(18)]);
}
onMousePress("left", () => {
const p = mousePos();
if (inside(p, SCREEN)) fire(p);
else reload(); // shooting off-screen reloads, as with a real light gun
});
// #endregion
// #region reload
// With six shots, reloading is a decision: when is it safe to look away?
function reload() {
if (reloading > 0 || ammo === MAGAZINE) return;
reloading = RELOAD_TIME;
}
onMousePress("right", reload);
onButtonPress("reload", reload);
onUpdate(() => {
if (reloading > 0) {
reloading -= dt();
if (reloading <= 0) ammo = MAGAZINE;
}
warning.text = reloading > 0 ? "…" : ammo === 0 && Math.floor(time() * 3) % 2 ? t({ es: "¡RECARGA!", en: "RELOAD!" }) : "";
});
// #endregion
// #region lives-score
function loseLife() {
stats.lives--;
shake(10);
flash(RED, 0.3);
if (stats.lives <= 0) go("end", false, stats);
}
onUpdate(() => {
const acc = stats.shots ? Math.round((100 * stats.hits) / stats.shots) : 100;
hud.text = t({
es: `Puntos ${stats.score} Vidas ${"♥".repeat(stats.lives)} Precisión ${acc}% Balas ${"▮".repeat(ammo)}`,
en: `Score ${stats.score} Lives ${"♥".repeat(stats.lives)} Accuracy ${acc}% Ammo ${"▮".repeat(ammo)}`,
});
if (step >= steps.length && get("target").length === 0) go("end", true, stats);
});
// The crosshair is drawn last, on top of everything.
add([z(30), { draw() { const m = mousePos(); drawCircle({ pos: m, radius: 10, fill: false, outline: { width: 2, color: WHITE } }); drawCircle({ pos: m, radius: 2, color: WHITE }); } }]);
// #endregion
});
// #region scenes
function screen(title, subtitle, next) {
setCursor("default");
add([text(title, { size: 32, font: FONT, align: "center" }), pos(center().sub(0, 70)), anchor("center")]);
add([
text(subtitle, { size: 15, font: FONT, align: "center", width: 580 }),
pos(center().add(0, 30)), anchor("center"), color(200, 210, 255),
]);
onButtonPress("start", next);
onClick(next);
}
scene("title", () =>
screen(
t({ es: "Galería de tiro", en: "Shooting gallery" }),
t({
es: "Apunta con el ratón o el dedo y dispara a las figuras rojas antes de que te disparen.\nNo dispares a las verdes, que tienen las manos en alto.\nTienes seis balas: recarga con clic derecho, con R o disparando al marco negro.\nCuanto más cerca del centro de la diana, más puntos.\nHaz clic o pulsa espacio para empezar.",
en: "Aim with the mouse or your finger and shoot the red figures before they shoot you.\nDo not shoot the green ones with their hands up.\nYou have six shots: reload with right-click, with R or by shooting the black frame.\nThe closer to the centre of the bullseye, the more points.\nClick or press space to start.",
}),
() => go("game"),
),
);
scene("end", (won, s) => {
const acc = s.shots ? Math.round((100 * s.hits) / s.shots) : 0;
screen(
won ? t({ es: "¡Ronda superada!", en: "Round cleared!" }) : t({ es: "Fin de la partida", en: "Game over" }),
t({
es: `Puntos: ${s.score} Precisión: ${acc}% Civiles alcanzados: ${s.civilians}\nHaz clic o pulsa espacio para repetir.`,
en: `Score: ${s.score} Accuracy: ${acc}% Civilians hit: ${s.civilians}\nClick or press space to play again.`,
}),
() => go("game"),
);
});
go("title");
// #endregionDesign challenges
- Making enemies and civilians tell apart in a split second, by colour, silhouette and pose rather than by small details.
- Clearly warning before an enemy fires (a visible countdown) so that losing a life feels fair.
- A rhythm of appearances that alternates pressure with lulls, so that reloading is a decision rather than a chore.
- Designing for the device players really have, because classic light guns needed CRT televisions.
History
- 1972Origin
The Magnavox Odyssey console (1972) already had a light gun accessory; in arcades, the first were Balloon Gun (Sega) and Qwak! (Atari), in 1974. Mechanical light-gun games existed since the 1930s.
Light-gun shooter (Wikipedia) - 1984Duck Hunt
Duck Hunt comes out for the Famicom, and in 1985 it joins the NES launch in North America along with the Zapper gun.
Duck Hunt (Wikipedia) - 1987Operation Wolf
Taito's Operation Wolf popularises military-themed light-gun games on a fixed path.
Light-gun shooter (Wikipedia) - 1994Virtua Cop
Sega's Virtua Cop popularises 3D polygon graphics in light-gun games and starts a renaissance of the genre in arcades.
Light-gun shooter (Wikipedia) - 1995Time Crisis
Time Crisis adds a pedal for taking cover and simulated recoil.
Light-gun shooter (Wikipedia) - 1997The House of the Dead
The House of the Dead starts the most successful horror light-gun series.
Light-gun shooter (Wikipedia) - 2006
The Wii Remote, which you point at the screen, brings a small renaissance of the genre on consoles.
Light-gun shooter (Wikipedia) - 2020
The Sinden light gun works on LCD monitors and TVs by tracking a white border drawn around the edges of the screen.
Sinden Light Gun (Wikipedia)
Reference games
1984
Duck Hunt
You aim the Zapper gun at a CRT television and have three shots per round to bring down the ducks that fly up. It was one of the NES launch games in North America.
1987
Operation Wolf
The gun is shaped like a submachine gun and vibrates as you fire. The view moves by itself while you shoot soldiers and vehicles, avoid nurses and children, and refill ammo by shooting objects. Its success set the model for military-themed light-gun games on a fixed path.
1994
Virtua Cop
A light-gun game with real-time 3D polygon graphics. You have a six-shot chamber, hurting a civilian costs a life and disarming an enemy by shooting their hand gives extra points.
1995
Time Crisis
A pedal decides when you take cover to reload and avoid enemy fire, and each section has a time limit. Hiding and leaning out becomes part of the rhythm.
1997
The House of the Dead
A horror light-gun game in which two agents face an army of undead experiments created by a mad scientist. It starts the most successful horror light-gun series.
2021
Pixel Crisis
An independent 2D homage to Time Crisis with sprite graphics. You play with mouse and keyboard or with a Sinden light gun, and its author publishes it free and playable in the browser. It is still in development.
Exercise
In the demo, change the round's script (the string of letters and numbers) and how long each figure stays in view, and look for a rhythm that forces you to decide when to reload. Then add a third kind of figure, for example one that needs two shots, and check whether it can be told apart at a glance.