Skip to content
GameGenresEspañol
Shooter

Shoot 'em up

A shooter in which you steer a ship or character across a plane seen from the side or from above, firing nonstop at waves of enemies while dodging their projectiles.

Also known as: shmup, STG, shoot-em-up

Defining mechanics

  • Free movement on two axes within the screen and near-continuous fire; both happen at the same time.
  • Enemy waves that enter along fixed paths and formations, learnable from one run to the next.
  • Enemy projectiles in readable patterns (straight, fanned, aimed) that turn the screen into a spatial puzzle.
  • Weapon upgrades, bombs and lives as resources; the score rewards taking risks.
  • Automatic scrolling that pushes the player forward, or a fixed screen.

Gameplay loop

Read the incoming pattern, move into a safe gap you can shoot from, clear the wave and get ready for the next one.

Our demo

Controls. Arrows or WASD to move and space or Z (held) to fire. With a mouse or finger, hold down and the ship follows the pointer while firing. On mobile there are also on-screen buttons.

Open full screen

A ship in a self-scrolling starfield, five enemy waves with fixed paths and two enemy bullet patterns. Just enough to feel what defines the genre, which is firing nonstop while you read and dodge what is coming.

Made with Kaplay, in about 192 lines of JavaScript.

How it's made

  1. Setup and controls

    The game is created at a fixed size with virtual buttons for the four directions and firing, so the same code works with a keyboard or a touch screen. The speed and fire-rate constants define how powerful you feel. The enemy bullet colour is reserved for them, because in this genre the first thing is telling apart what kills you.

    const k = kaplay({
      width: 640,
      height: 360,
      letterbox: true, pixelDensity,
      background: [10, 12, 36],
      buttons: {
        up: { keyboard: ["up", "w"] },
        down: { keyboard: ["down", "s"] },
        left: { keyboard: ["left", "a"] },
        right: { keyboard: ["right", "d"] },
        fire: { keyboard: ["space", "z", "j"] },
      },
    });
    touchControls(k, [
      { button: "left", label: "◀" },
      { button: "up", label: "▲" },
      { button: "down", label: "▼" },
      { button: "right", label: "▶" },
      { button: "fire", label: "✦", side: "right" },
    ]);
    
    const SHIP_SPEED = 240;
    const BULLET_SPEED = 560;
    const FIRE_RATE = 0.12; // seconds between your own shots
    const ENEMY_BULLET_COLOR = rgb(255, 80, 200); // a colour used only by enemy bullets
  2. The scrolling background

    The stars move left at different speeds and come back in from the right. That parallax effect gives the feeling of flying forward, like the automatic scrolling of side-scrolling shoot 'em ups, without moving the camera.

    // Stars drifting left give the feeling of moving forward without moving the
    // camera, like the auto-scrolling of side-scrolling shoot 'em ups.
    function starfield() {
      for (let i = 0; i < 50; i++) {
        const speed = rand(30, 140);
        add([rect(2, 2), pos(rand(0, width()), rand(0, height())), color(160, 170, 220), opacity(speed / 140), { speed }, "star"]);
      }
      onUpdate("star", (s) => {
        s.pos.x -= s.speed * dt();
        if (s.pos.x < 0) s.pos.x = width();
      });
    }
  3. The ship and its hitbox

    The ship moves freely on two axes without leaving the screen. Its drawing is large, but the area that takes damage is a 6-pixel square marked with a white dot. That gap is intentional in the genre, because it lets you graze bullets and makes near misses feel fair.

    // The drawing is a big arrow, but the hitbox is a 6 px square in the
    // centre (the white dot). So you can graze bullets without dying.
    const ship = add([
      polygon([vec2(-14, -10), vec2(16, 0), vec2(-14, 10), vec2(-8, 0)]),
      color(90, 200, 255),
      pos(80, height() / 2),
      area({ shape: new Rect(vec2(-3, -3), 6, 6) }),
      opacity(1),
      { invulnerable: 0 },
      "ship",
    ]);
    ship.add([circle(3), color(255, 255, 255)]);
    
    ship.onUpdate(() => {
      let dir = vec2(0, 0);
      if (isButtonDown("left")) dir.x -= 1;
      if (isButtonDown("right")) dir.x += 1;
      if (isButtonDown("up")) dir.y -= 1;
      if (isButtonDown("down")) dir.y += 1;
      // With a mouse or finger: the ship heads to the pointer while you hold it down.
      if (isMouseDown()) dir = mousePos().sub(ship.pos).scale(1 / 40);
      if (dir.len() > 1) dir = dir.unit();
      ship.move(dir.scale(SHIP_SPEED));
      ship.pos.x = clamp(ship.pos.x, 16, width() - 16);
      ship.pos.y = clamp(ship.pos.y, 16, height() - 16);
      // Blink while the invulnerability after a hit lasts.
      ship.invulnerable = Math.max(0, ship.invulnerable - dt());
      ship.opacity = ship.invulnerable > 0 && Math.floor(time() * 12) % 2 ? 0.2 : 1;
    });
  4. Continuous fire

    Holding the button fires a burst at a fixed rate. Since firing needs no attention, your mind is free to move, and that double task of shooting and dodging at once is the heart of the shoot 'em up. Each enemy takes two hits.

    // Holding the button fires a continuous stream: the player shoots and moves
    // at the same time, and that double task is the heart of the genre.
    let cooldown = 0;
    onUpdate(() => {
      cooldown -= dt();
      if ((isButtonDown("fire") || isMouseDown()) && cooldown <= 0) {
        cooldown = FIRE_RATE;
        add([rect(14, 4), color(255, 230, 90), pos(ship.pos.add(14, 0)), anchor("center"), area(), move(RIGHT, BULLET_SPEED), offscreen({ destroy: true }), "bullet"]);
      }
    });
    onCollide("bullet", "enemy", (b, e) => {
      destroy(b);
      e.hp--;
      if (e.hp <= 0) {
        destroy(e);
        score += 100;
        updateHud();
      }
    });
  5. Enemy paths

    Each enemy follows a fixed pattern: straight, wave or dive (it enters, stops for a moment and charges at you). Being fixed makes them learnable, and the pause before the dive is a warning that gives you time to react.

    // Each enemy follows a fixed path given by its pattern. Being fixed is
    // what makes it learnable: the second time you know where it comes from.
    const PATTERNS = {
      straight: (e) => vec2(-e.speed, 0),
      sine: (e) => vec2(-e.speed, Math.cos(e.age * 3) * 120),
      // It enters, stops for a moment (the warning) and charges at where the ship is.
      dive: (e) => {
        if (e.age < 1) return vec2(-e.speed, 0);
        if (e.age < 1.6) return vec2(0, 0);
        e.heading ??= ship.pos.sub(e.pos).unit();
        return e.heading.scale(e.speed * 2.2);
      },
    };
    function spawnEnemy(pattern, shot, y, speed) {
      const e = add([
        rect(26, 22), color(240, 110, 70), pos(width() + 20, y), anchor("center"), area(),
        offscreen({ destroy: true, distance: 60 }),
        { pattern, shot, speed, age: 0, hp: 2, cooldown: rand(0.8, 1.4), heading: null },
        "enemy",
      ]);
      e.onUpdate(() => {
        e.age += dt();
        e.move(PATTERNS[e.pattern](e));
        enemyFire(e);
      });
    }
  6. Bullet patterns

    There are two patterns, the aimed bullet, which flies to where you are, and the fan of five spread bullets. Bullets are slow, round and have their own colour. A good pattern reads at a glance and forces a specific movement. You dodge the aimed one with a sidestep and the fan by finding the gap between bullets.

    // Two bullet patterns: "aimed" (flies at the ship) and "fan" (five spread
    // bullets). They are slow, big and have their own colour so you can read them in time.
    function enemyBullet(origin, direction) {
      add([circle(5), color(ENEMY_BULLET_COLOR), pos(origin), anchor("center"), area(), move(direction, 150), offscreen({ destroy: true }), "enemy-bullet"]);
    }
    function enemyFire(e) {
      e.cooldown -= dt();
      if (e.shot === "none" || e.cooldown > 0 || e.pos.x > width() - 10) return;
      e.cooldown = 1.6;
      const towardShip = ship.pos.sub(e.pos).angle();
      if (e.shot === "aimed") enemyBullet(e.pos, Vec2.fromAngle(towardShip));
      if (e.shot === "fan") for (let i = -2; i <= 2; i++) enemyBullet(e.pos, Vec2.fromAngle(180 + i * 18));
    }
  7. Waves as data

    The level is a list of waves, each with its pattern, firing type, number of ships and entry heights. When the last ship is destroyed or leaves, the next wave begins. When the list ends it starts again, faster. Designing with data lets you tune the rhythm of pressure and rest without touching the logic.

    // The level is a list of waves. When the last ship of one falls, the next begins.
    // It is the same trick as the platformer's text map: designing with data.
    const WAVES = [
      { pattern: "straight", shot: "none", n: 5, ys: [60, 120, 180, 240, 300] },
      { pattern: "sine", shot: "aimed", n: 6, ys: [120] },
      { pattern: "dive", shot: "none", n: 4, ys: [60, 300, 120, 240] },
      { pattern: "straight", shot: "fan", n: 3, ys: [90, 180, 270] },
      { pattern: "sine", shot: "fan", n: 6, ys: [100, 260] },
    ];
    let waveNumber = 0;
    function launchWave() {
      const w = WAVES[waveNumber % WAVES.length];
      const lap = Math.floor(waveNumber / WAVES.length);
      waveNumber++;
      banner.text = t({ es: `Oleada ${waveNumber}`, en: `Wave ${waveNumber}` });
      wait(1, () => (banner.text = ""));
      for (let i = 0; i < w.n; i++) {
        // Each full loop through the list makes the enemies faster.
        wait(0.8 + i * 0.45, () => spawnEnemy(w.pattern, w.shot, w.ys[i % w.ys.length], 120 + lap * 30));
      }
      wait(0.8 + w.n * 0.45 + 0.5, () => (waiting = false));
    }
    const banner = add([text("", { size: 26, font: FONT }), pos(center()), anchor("center"), color(200, 210, 255)]);
    let waiting = true;
    launchWave();
    onUpdate(() => {
      if (!waiting && get("enemy").length === 0) {
        waiting = true;
        wait(0.8, launchWave);
      }
    });
  8. Lives, invulnerability and score

    Touching a bullet or an enemy ship costs a life, shakes the screen and leaves the ship blinking and invulnerable for a second and a half, so you do not lose several lives at once. Each enemy shot down gives 100 points, the record of how well you played.

    // Touching a bullet or an enemy ship costs a life and gives a moment of
    // invulnerability so you do not lose several at once.
    function takeHit() {
      if (ship.invulnerable > 0) return;
      lives--;
      updateHud();
      shake(8);
      ship.invulnerable = 1.5;
      if (lives <= 0) go("end", score);
    }
    ship.onCollide("enemy-bullet", (b) => {
      destroy(b);
      takeHit();
    });
    ship.onCollide("enemy", takeHit);
  9. Scenes

    The game has three scenes, title, game and end, and go() jumps between them. Restarting means entering the game scene again, which creates everything anew.

    function screen(title, subtitle, next) {
      starfield();
      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("fire", next);
      onKeyPress("enter", next);
      onClick(next);
    }
    
    scene("title", () =>
      screen(
        t({ es: "Shoot 'em up", en: "Shoot 'em up" }),
        t({
          es: "Flechas o WASD para moverte, mantén espacio o Z para disparar.\nCon ratón o dedo: mantén pulsado y la nave sigue al puntero disparando.\nEsquiva las balas rosas: solo el punto blanco de tu nave recibe daño.\nPulsa espacio o haz clic para empezar.",
          en: "Arrows or WASD to move, hold space or Z to fire.\nWith a mouse or finger: hold down and the ship follows the pointer while firing.\nDodge the pink bullets: only the white dot on your ship takes damage.\nPress space or click to start.",
        }),
        () => go("game"),
      ),
    );
    
    scene("end", (score) =>
      screen(
        t({ es: "Fin de la partida", en: "Game over" }),
        t({ es: `Puntos: ${score}. Pulsa espacio o haz clic para repetir.`, en: `Score: ${score}. Press space or click to play again.` }),
        () => go("game"),
      ),
    );
    
    go("title");

What we simplified

  • Ships, enemies and bullets are simple shapes; there is no sound, no explosions, no weapon upgrades and no bombs.
  • There is no boss and no ending; the five waves repeat faster and faster until you lose all three lives.
  • The scrolling is only visual (the stars move); there is no stage with obstacles.

To modify it

  • Add a third bullet pattern, for example a ring of eight bullets in every direction, and create a wave that uses it.
  • Make destroyed enemies sometimes drop a power-up that fires two parallel bullets for a few seconds.
  • Program a boss at the end of the five waves, with lots of health and two different firing phases.
  • Add a slow mode (for example with Shift) that reduces the ship's speed for precise dodging, as in bullet hell games.
Full code
// Shoot 'em up 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: [10, 12, 36],
  buttons: {
    up: { keyboard: ["up", "w"] },
    down: { keyboard: ["down", "s"] },
    left: { keyboard: ["left", "a"] },
    right: { keyboard: ["right", "d"] },
    fire: { keyboard: ["space", "z", "j"] },
  },
});
touchControls(k, [
  { button: "left", label: "◀" },
  { button: "up", label: "▲" },
  { button: "down", label: "▼" },
  { button: "right", label: "▶" },
  { button: "fire", label: "✦", side: "right" },
]);

const SHIP_SPEED = 240;
const BULLET_SPEED = 560;
const FIRE_RATE = 0.12; // seconds between your own shots
const ENEMY_BULLET_COLOR = rgb(255, 80, 200); // a colour used only by enemy bullets
// #endregion

// #region background
// Stars drifting left give the feeling of moving forward without moving the
// camera, like the auto-scrolling of side-scrolling shoot 'em ups.
function starfield() {
  for (let i = 0; i < 50; i++) {
    const speed = rand(30, 140);
    add([rect(2, 2), pos(rand(0, width()), rand(0, height())), color(160, 170, 220), opacity(speed / 140), { speed }, "star"]);
  }
  onUpdate("star", (s) => {
    s.pos.x -= s.speed * dt();
    if (s.pos.x < 0) s.pos.x = width();
  });
}
// #endregion

scene("game", () => {
  starfield();
  let score = 0;
  let lives = 3;
  const hud = add([text("", { size: 16, font: FONT }), pos(12, 10), z(10)]);
  const updateHud = () =>
    (hud.text = t({ es: `Puntos: ${score}   Vidas: ${lives}`, en: `Score: ${score}   Lives: ${lives}` }));
  updateHud();

  // #region ship
  // The drawing is a big arrow, but the hitbox is a 6 px square in the
  // centre (the white dot). So you can graze bullets without dying.
  const ship = add([
    polygon([vec2(-14, -10), vec2(16, 0), vec2(-14, 10), vec2(-8, 0)]),
    color(90, 200, 255),
    pos(80, height() / 2),
    area({ shape: new Rect(vec2(-3, -3), 6, 6) }),
    opacity(1),
    { invulnerable: 0 },
    "ship",
  ]);
  ship.add([circle(3), color(255, 255, 255)]);

  ship.onUpdate(() => {
    let dir = vec2(0, 0);
    if (isButtonDown("left")) dir.x -= 1;
    if (isButtonDown("right")) dir.x += 1;
    if (isButtonDown("up")) dir.y -= 1;
    if (isButtonDown("down")) dir.y += 1;
    // With a mouse or finger: the ship heads to the pointer while you hold it down.
    if (isMouseDown()) dir = mousePos().sub(ship.pos).scale(1 / 40);
    if (dir.len() > 1) dir = dir.unit();
    ship.move(dir.scale(SHIP_SPEED));
    ship.pos.x = clamp(ship.pos.x, 16, width() - 16);
    ship.pos.y = clamp(ship.pos.y, 16, height() - 16);
    // Blink while the invulnerability after a hit lasts.
    ship.invulnerable = Math.max(0, ship.invulnerable - dt());
    ship.opacity = ship.invulnerable > 0 && Math.floor(time() * 12) % 2 ? 0.2 : 1;
  });
  // #endregion

  // #region shooting
  // Holding the button fires a continuous stream: the player shoots and moves
  // at the same time, and that double task is the heart of the genre.
  let cooldown = 0;
  onUpdate(() => {
    cooldown -= dt();
    if ((isButtonDown("fire") || isMouseDown()) && cooldown <= 0) {
      cooldown = FIRE_RATE;
      add([rect(14, 4), color(255, 230, 90), pos(ship.pos.add(14, 0)), anchor("center"), area(), move(RIGHT, BULLET_SPEED), offscreen({ destroy: true }), "bullet"]);
    }
  });
  onCollide("bullet", "enemy", (b, e) => {
    destroy(b);
    e.hp--;
    if (e.hp <= 0) {
      destroy(e);
      score += 100;
      updateHud();
    }
  });
  // #endregion

  // #region enemy-patterns
  // Each enemy follows a fixed path given by its pattern. Being fixed is
  // what makes it learnable: the second time you know where it comes from.
  const PATTERNS = {
    straight: (e) => vec2(-e.speed, 0),
    sine: (e) => vec2(-e.speed, Math.cos(e.age * 3) * 120),
    // It enters, stops for a moment (the warning) and charges at where the ship is.
    dive: (e) => {
      if (e.age < 1) return vec2(-e.speed, 0);
      if (e.age < 1.6) return vec2(0, 0);
      e.heading ??= ship.pos.sub(e.pos).unit();
      return e.heading.scale(e.speed * 2.2);
    },
  };
  function spawnEnemy(pattern, shot, y, speed) {
    const e = add([
      rect(26, 22), color(240, 110, 70), pos(width() + 20, y), anchor("center"), area(),
      offscreen({ destroy: true, distance: 60 }),
      { pattern, shot, speed, age: 0, hp: 2, cooldown: rand(0.8, 1.4), heading: null },
      "enemy",
    ]);
    e.onUpdate(() => {
      e.age += dt();
      e.move(PATTERNS[e.pattern](e));
      enemyFire(e);
    });
  }
  // #endregion

  // #region enemy-bullets
  // Two bullet patterns: "aimed" (flies at the ship) and "fan" (five spread
  // bullets). They are slow, big and have their own colour so you can read them in time.
  function enemyBullet(origin, direction) {
    add([circle(5), color(ENEMY_BULLET_COLOR), pos(origin), anchor("center"), area(), move(direction, 150), offscreen({ destroy: true }), "enemy-bullet"]);
  }
  function enemyFire(e) {
    e.cooldown -= dt();
    if (e.shot === "none" || e.cooldown > 0 || e.pos.x > width() - 10) return;
    e.cooldown = 1.6;
    const towardShip = ship.pos.sub(e.pos).angle();
    if (e.shot === "aimed") enemyBullet(e.pos, Vec2.fromAngle(towardShip));
    if (e.shot === "fan") for (let i = -2; i <= 2; i++) enemyBullet(e.pos, Vec2.fromAngle(180 + i * 18));
  }
  // #endregion

  // #region waves
  // The level is a list of waves. When the last ship of one falls, the next begins.
  // It is the same trick as the platformer's text map: designing with data.
  const WAVES = [
    { pattern: "straight", shot: "none", n: 5, ys: [60, 120, 180, 240, 300] },
    { pattern: "sine", shot: "aimed", n: 6, ys: [120] },
    { pattern: "dive", shot: "none", n: 4, ys: [60, 300, 120, 240] },
    { pattern: "straight", shot: "fan", n: 3, ys: [90, 180, 270] },
    { pattern: "sine", shot: "fan", n: 6, ys: [100, 260] },
  ];
  let waveNumber = 0;
  function launchWave() {
    const w = WAVES[waveNumber % WAVES.length];
    const lap = Math.floor(waveNumber / WAVES.length);
    waveNumber++;
    banner.text = t({ es: `Oleada ${waveNumber}`, en: `Wave ${waveNumber}` });
    wait(1, () => (banner.text = ""));
    for (let i = 0; i < w.n; i++) {
      // Each full loop through the list makes the enemies faster.
      wait(0.8 + i * 0.45, () => spawnEnemy(w.pattern, w.shot, w.ys[i % w.ys.length], 120 + lap * 30));
    }
    wait(0.8 + w.n * 0.45 + 0.5, () => (waiting = false));
  }
  const banner = add([text("", { size: 26, font: FONT }), pos(center()), anchor("center"), color(200, 210, 255)]);
  let waiting = true;
  launchWave();
  onUpdate(() => {
    if (!waiting && get("enemy").length === 0) {
      waiting = true;
      wait(0.8, launchWave);
    }
  });
  // #endregion

  // #region lives-score
  // Touching a bullet or an enemy ship costs a life and gives a moment of
  // invulnerability so you do not lose several at once.
  function takeHit() {
    if (ship.invulnerable > 0) return;
    lives--;
    updateHud();
    shake(8);
    ship.invulnerable = 1.5;
    if (lives <= 0) go("end", score);
  }
  ship.onCollide("enemy-bullet", (b) => {
    destroy(b);
    takeHit();
  });
  ship.onCollide("enemy", takeHit);
  // #endregion
});

// #region scenes
function screen(title, subtitle, next) {
  starfield();
  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("fire", next);
  onKeyPress("enter", next);
  onClick(next);
}

scene("title", () =>
  screen(
    t({ es: "Shoot 'em up", en: "Shoot 'em up" }),
    t({
      es: "Flechas o WASD para moverte, mantén espacio o Z para disparar.\nCon ratón o dedo: mantén pulsado y la nave sigue al puntero disparando.\nEsquiva las balas rosas: solo el punto blanco de tu nave recibe daño.\nPulsa espacio o haz clic para empezar.",
      en: "Arrows or WASD to move, hold space or Z to fire.\nWith a mouse or finger: hold down and the ship follows the pointer while firing.\nDodge the pink bullets: only the white dot on your ship takes damage.\nPress space or click to start.",
    }),
    () => go("game"),
  ),
);

scene("end", (score) =>
  screen(
    t({ es: "Fin de la partida", en: "Game over" }),
    t({ es: `Puntos: ${score}. Pulsa espacio o haz clic para repetir.`, en: `Score: ${score}. Press space or click to play again.` }),
    () => go("game"),
  ),
);

go("title");
// #endregion

Design challenges

  • Making enemy shots always stand out from the background and from the player's own, by colour, shape and size.
  • Designing patterns that can be read in time; if the player cannot see the bullet coming, losing feels unfair.
  • Tuning the player's hitbox, often smaller than the sprite, so that near-miss dodging is possible.
  • Pacing waves with moments of pressure and of rest, and bosses that sum up what was learned.
  • Balancing weapon power against enemy density so upgrades are felt without breaking the challenge.

History

  1. 1978
    OriginSpace Invaders

    Space Invaders is the title most often cited as the genre's first and set its template. Some authors trace the genre back to Spacewar! (1962).

    Shoot 'em up (Wikipedia)
  2. 1979
    Galaxian

    Galaxian brings colour graphics and enemies that break formation to dive at the player.

    Galaxian (Wikipedia)
  3. 1981
    Scramble

    Scramble is the first shoot 'em up with forced side-scrolling and several distinct levels.

    Scramble (Wikipedia)
  4. 1985
    Gradius

    Gradius defines the side-scrolling shoot 'em up, and its power-up bar lets the player choose how to upgrade the ship.

    Gradius (Wikipedia)
  5. 1987
    R-Type

    R-Type adds the charge shot and the Force, a pod that works as both weapon and shield.

    R-Type (Wikipedia)
  6. 1993
    Batsugun

    Batsugun fills the screen with bullet patterns and opens the way to bullet hell.

    Batsugun (Wikipedia)
  7. 2001
    Ikaruga

    Ikaruga lets the ship switch between white and black to absorb bullets of its own colour, and the whole design revolves around that rule.

    Ikaruga (Wikipedia)
  8. 2013
    Radius Raid

    Radius Raid wins the js13kGames competition with a browser space shoot 'em up that weighs under 13 kilobytes.

    Js13kGames (Wikipedia)

Reference games

1978

Space Invaders

Taito (Tomohiro Nishikado). arcade

You move a cannon along the bottom of the screen and shoot rows of invaders that descend ever faster, sheltered by bunkers that wear away. It set the shoot 'em up template.

1979

Galaxian

Namco. arcade

A formation of colour aliens from which ships dive at the player while firing. It turned enemies into attack patterns that must be read.

1981

Scramble

Konami. arcade

A plane flies over side-scrolling terrain on its own, shooting and dropping bombs, and burns fuel that it refills by destroying tanks across six sections.

1985

Gradius

Konami. arcade

A side-scrolling shoot 'em up in which each capsule you collect advances a power-up bar and you decide when to activate the selected upgrade. It was called Nemesis in North America and Europe.

1987

R-Type

Irem. arcade

A side-scrolling shoot 'em up with a shot that charges while you hold the button, and the Force, a pod that attaches to the front or back of the ship or is detached to act as a shield.

1993

Batsugun

Toaplan. arcade

A vertical shoot 'em up with complex waves, dense bullet patterns and heavy firepower. It is considered the first «manic shooter», the forerunner of bullet hell.

2001

Ikaruga

Treasure. arcade

The ship switches between white and black whenever you want. It absorbs bullets of its own colour to charge a laser and dies from those of the opposite colour, so dodging also becomes choosing.

2013

Radius Raid

Jack Rugile. navegador

A space shoot 'em up in which you move with the keyboard and aim with the mouse against 13 enemy types. It won js13kGames 2013, and its author published it playable in the browser and open source.

Exercise

Play the demo and note which wave is hardest and why. Then change one enemy's firing pattern (more bullets in the fan, or aimed bullets) and test whether it is still readable. Finally, enlarge the player's hitbox and discuss how the sense of fairness changes.

Subgenres

  • Bullet hellA shoot 'em up that fills the screen with hundreds of projectiles in intricate patterns; your ship is vulnerable only at a tiny point, and the challenge is weaving between the bullets.
  • Run and gunYou advance on foot through scrolling levels, running, jumping and firing nonstop at enemies coming from every side.
  • Twin-stick shooterOne stick moves your character and the other fires in any direction, regardless of where you walk, while you survive surrounding hordes seen from above.

Sources