Skip to content
GameGenresEspañol
Shooter

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 screen

A 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

  1. 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);
  2. 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;
  3. 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;
      });
    }
  4. 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);
    });
  5. 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
    });
  6. 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!" }) : "";
    });
  7. 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 }); } }]);
  8. 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");
// #endregion

Design 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

  1. 1972
    Origin

    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)
  2. 1984
    Duck 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)
  3. 1987
    Operation Wolf

    Taito's Operation Wolf popularises military-themed light-gun games on a fixed path.

    Light-gun shooter (Wikipedia)
  4. 1994
    Virtua 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)
  5. 1995
    Time Crisis

    Time Crisis adds a pedal for taking cover and simulated recoil.

    Light-gun shooter (Wikipedia)
  6. 1997
    The House of the Dead

    The House of the Dead starts the most successful horror light-gun series.

    Light-gun shooter (Wikipedia)
  7. 2006

    The Wii Remote, which you point at the screen, brings a small renaissance of the genre on consoles.

    Light-gun shooter (Wikipedia)
  8. 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

Nintendo. Famicom/NES, arcade

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

Taito. arcade

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

Sega. arcade, Sega Saturn, Windows

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

Namco. arcade, PlayStation

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

Sega AM1. arcade

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

ThePrinceofMars. navegador, Windows, Linux

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.

Sources