Skip to content
GameGenresEspañol
Shooter

Bullet hell

A 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.

Also known as: danmaku, manic shooter

Defining mechanics

  • Dense, geometric bullet patterns (rings, fans, spirals) that read as shapes rather than as single shots.
  • A tiny hitbox, much smaller than the ship's sprite, which makes very narrow dodges fair.
  • Two movement speeds; focus mode slows you down to slip through narrow gaps and often shows the hitbox.
  • Points for grazing bullets without touching them, which reward taking risks.
  • Bosses with several phases, each with its own pattern, and bombs or phase changes that wipe the bullets off the screen.

Gameplay loop

Read the pattern as it forms, find the gap, slip into it with focus, shoot the boss when the pattern lets up and move on to the next phase.

Our demo

Controls. Arrows or WASD to move; the ship fires by itself. Hold Shift or X for focus mode (slow, with the hitbox visible). With a mouse or finger, hold down and the ship follows the pointer. On mobile there are also on-screen buttons, one of them for focus.

Open full screen

A ship on an upright screen facing a boss with three phases, each with a different bullet pattern (rings, a spiral, and a fan with a ring). Just enough to feel what defines the genre, which is reading geometric patterns and slipping through their gaps with a tiny hitbox.

Made with Kaplay, in about 200 lines of JavaScript.

How it's made

  1. Setup and controls

    The playfield is a vertical column in the middle, like an arcade monitor turned upright; the side panels show the score. There are two speeds, the normal one to cross the screen and the focus one for tight gaps. HITBOX is the radius of the only point that can be hit, and GRAZE the distance at which a bullet passing close gives points.

    const k = kaplay({
      width: 640,
      height: 360,
      letterbox: true, pixelDensity,
      background: [8, 6, 26],
      buttons: {
        up: { keyboard: ["up", "w"] },
        down: { keyboard: ["down", "s"] },
        left: { keyboard: ["left", "a"] },
        right: { keyboard: ["right", "d"] },
        focus: { keyboard: ["shift", "x"] },
        start: { keyboard: ["space", "enter", "z"] },
      },
    });
    touchControls(k, [
      { button: "left", label: "◀" },
      { button: "up", label: "▲" },
      { button: "down", label: "▼" },
      { button: "right", label: "▶" },
      { button: "focus", label: "◎", side: "right" },
    ]);
    
    // A vertical playfield in the middle of the screen, like an upright arcade monitor.
    const FIELD = { x: 170, w: 300, h: 360 };
    const SPEED = 210; // normal speed: to cross the screen
    const FOCUS_SPEED = 80; // focus speed: to thread small gaps
    const HITBOX = 2.5; // radius of the only point that can be hit
    const GRAZE = 14; // bullets passing this close (without touching) give points
    const PINK = rgb(255, 90, 200);
    const CYAN = rgb(90, 220, 255);
    const GOLD = rgb(255, 210, 80);
  2. Hundreds of bullets as data

    If each bullet were a game object with its own collisions, hundreds of them would slow the game down. Here each bullet is just a position, a velocity and a radius inside a list. A single object, the swarm, moves them all, removes the ones that leave the field and draws them in one loop. Bullet hell engines work this way.

    // Hundreds of bullets as game objects (each with area() and collision events) would be slow.
    // Here one object, the swarm, keeps every bullet as plain data {x, y, vx, vy, r} and
    // moves and draws them all in a single loop. Bullet hell engines work this way.
    let swarm;
    function addSwarm() {
      swarm = add([z(3), "swarm", {
        list: [],
        update() {
          for (const b of this.list) {
            b.x += b.vx * dt();
            b.y += b.vy * dt();
          }
          this.list = this.list.filter((b) => b.x > FIELD.x - 10 && b.x < FIELD.x + FIELD.w + 10 && b.y > -10 && b.y < FIELD.h + 10);
        },
        draw() {
          for (const b of this.list) drawCircle({ pos: vec2(b.x, b.y), radius: b.r, color: b.col, outline: { width: 1.5, color: WHITE } });
        },
      }]);
    }
    function shoot(x, y, angle, speed, r = 5, col = PINK) {
      const a = deg2rad(angle);
      swarm.list.push({ x, y, vx: Math.cos(a) * speed, vy: Math.sin(a) * speed, r, col, grazed: false });
    }
  3. Patterns with trigonometry

    Each bullet leaves with an angle and a speed, and cosine and sine turn them into horizontal and vertical movement. The ring spreads n bullets every 360/n degrees. The fan uses atan2 to work out the angle towards the player and opens a few bullets around it. The spiral fires a small ring again and again, turning it a few degrees each time: each bullet flies straight and the spiral only appears in the trail they leave.

    // The three classic patterns are pure trigonometry: each bullet is an angle and a speed.
    // Ring: n bullets evenly spread around a circle, 360/n degrees apart.
    function ring(x, y, n, speed, offset = 0, col = PINK) {
      for (let i = 0; i < n; i++) shoot(x, y, offset + (i * 360) / n, speed, 5, col);
    }
    // Fan: a few bullets spread around the direction toward the player (atan2 gives that angle).
    function fan(x, y, n, spread, speed, target) {
      const aim = rad2deg(Math.atan2(target.y - y, target.x - x));
      for (let i = 0; i < n; i++) shoot(x, y, aim + (i - (n - 1) / 2) * spread, speed, 4, GOLD);
    }
    // Spiral: a small ring fired again and again, turning a few degrees each time.
    // Each bullet flies straight; the spiral only appears in the trail they leave.
    function spiral(x, y, arms, angle, speed) {
      for (let i = 0; i < arms; i++) shoot(x, y, angle + (i * 360) / arms, speed, 4, CYAN);
    }
  4. The ship and focus

    The ship is a large triangle, but only its centre point can be hit. While you hold focus you move more slowly and that point is drawn, along with a halo that marks the graze distance. Firing is automatic because in this genre your hands are busy dodging. With a mouse or finger the ship heads to the pointer; on touch screens it stays a little above the finger so you can see it.

    const player = add([
      polygon([vec2(0, -12), vec2(10, 10), vec2(0, 5), vec2(-10, 10)]),
      color(140, 200, 255), pos(FIELD.x + FIELD.w / 2, 320), opacity(1), z(4),
      { lives: 3, invulnerable: 0, cooldown: 0 },
    ]);
    // The hitbox: a dot of 2.5 px that is only drawn while you focus.
    const dot = player.add([circle(HITBOX + 1), color(WHITE), outline(1.5, rgb(255, 40, 80)), opacity(0)]);
    const aura = player.add([circle(GRAZE), color(WHITE), opacity(0)]);
    const touch = matchMedia("(pointer: coarse)").matches;
    
    player.onUpdate(() => {
      const focus = isButtonDown("focus");
      const speed = focus ? FOCUS_SPEED : SPEED;
      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;
      // Mouse or finger: the ship goes to the pointer (above the finger, so you can see it).
      if (isMouseDown()) dir = mousePos().sub(0, touch ? 50 : 0).sub(player.pos).scale(1 / 12);
      if (dir.len() > 1) dir = dir.unit();
      player.move(dir.scale(speed));
      player.pos.x = clamp(player.pos.x, FIELD.x + 10, FIELD.x + FIELD.w - 10);
      player.pos.y = clamp(player.pos.y, 20, FIELD.h - 12);
      dot.opacity = focus ? 1 : 0;
      aura.opacity = focus ? 0.12 : 0;
      // Shots are automatic: in this genre your hands are busy dodging.
      player.cooldown -= dt();
      if (player.cooldown <= 0) {
        player.cooldown = 0.08;
        add([rect(3, 12), color(200, 255, 200), pos(player.pos.sub(0, 12)), anchor("center"), area(), move(UP, 700), offscreen({ destroy: true }), "shot"]);
      }
      player.invulnerable = Math.max(0, player.invulnerable - dt());
      player.opacity = player.invulnerable > 0 && Math.floor(time() * 12) % 2 ? 0.25 : 1;
    });
  5. A boss in phases

    The boss's phases are a list of data with a name, a health value, how often it fires and which pattern it uses. Before each phase there is a pause in which the boss neither fires nor takes damage. When a phase ends its bullets vanish and each one gives points, a reward and a breather before a harder pattern. In the spiral, the spin reverses every 60 shots, so you have to change direction too.

    // Three phases, each with its own health and pattern. When a phase ends its bullets
    // vanish (a reward and a breather) and the next, harder one begins.
    const PHASES = [
      { name: { es: "Anillos", en: "Rings" }, hp: 50, every: 0.75,
        fire: (b, n) => ring(b.pos.x, b.pos.y, 16, 85, n * 11) },
      { name: { es: "Espiral", en: "Spiral" }, hp: 60, every: 0.11,
        // Every 60 shots the spin reverses, so you have to change direction too.
        fire: (b, n) => spiral(b.pos.x, b.pos.y, 3, (b.spin += Math.floor(n / 60) % 2 ? -9 : 9), 95) },
      { name: { es: "Abanico y anillo", en: "Fan and ring" }, hp: 70, every: 0.55,
        fire: (b, n) => {
          fan(b.pos.x, b.pos.y, 5, 13, 150, player.pos);
          if (n % 3 === 0) ring(b.pos.x, b.pos.y, 22, 65, rand(0, 360));
        } },
    ];
    const boss = add([
      circle(22), color(200, 60, 120), outline(3, WHITE), pos(FIELD.x + FIELD.w / 2, 70), anchor("center"), area(), z(2),
      { phase: 0, hp: PHASES[0].hp, rest: 1.5, timer: 0, shots: 0, spin: 0, age: 0 },
    ]);
    const bar = add([rect(FIELD.w - 20, 5), color(255, 90, 200), pos(FIELD.x + 10, 8), z(6)]);
    boss.onUpdate(() => {
      boss.age += dt();
      boss.pos.x = FIELD.x + FIELD.w / 2 + Math.sin(boss.age * 0.7) * 90;
      const p = PHASES[boss.phase];
      bar.width = ((FIELD.w - 20) * boss.hp) / p.hp;
      boss.rest -= dt(); // a short pause before each phase
      if (boss.rest > 0) return;
      boss.timer -= dt();
      if (boss.timer <= 0) {
        boss.timer = p.every;
        p.fire(boss, boss.shots++);
      }
    });
    boss.onCollide("shot", (s) => {
      destroy(s);
      if (boss.rest > 0) return; // invulnerable between phases
      boss.hp--;
      score += 10;
      if (boss.hp > 0) return;
      score += swarm.list.length * 5; // every cancelled bullet is worth points
      swarm.list = [];
      boss.phase++;
      if (boss.phase === PHASES.length) return go("end", true, score, grazes);
      boss.hp = PHASES[boss.phase].hp;
      boss.shots = 0;
      boss.rest = 1.5;
      banner.text = t(PHASES[boss.phase].name);
      wait(1.4, () => (banner.text = ""));
    });
    banner.text = t(PHASES[0].name);
    wait(1.4, () => (banner.text = ""));
  6. Hits and grazes

    Checking a hit is measuring a distance. If the bullet's centre is closer than its radius plus the hitbox radius, it hits you. A wider ring detects grazes, which give points once per bullet. When you lose a life the bullets are wiped and you are invulnerable for two seconds, so you restart on fair terms.

    // Collision is just distance: a bullet hits if it is closer than its radius plus the
    // hitbox. A wider ring around it detects "grazes", near misses that earn points.
    onUpdate(() => {
      for (const b of swarm.list) {
        const d = Math.hypot(b.x - player.pos.x, b.y - player.pos.y);
        if (d < b.r + HITBOX && player.invulnerable <= 0) return loseLife();
        if (d < b.r + GRAZE && !b.grazed) {
          b.grazed = true;
          grazes++;
          score += 2;
        }
      }
      hud.text = t({
        es: `Puntos\n${score}\n\nRoces\n${grazes}\n\nVidas\n${"♥".repeat(player.lives)}\n\nFase ${boss.phase + 1}/3`,
        en: `Score\n${score}\n\nGrazes\n${grazes}\n\nLives\n${"♥".repeat(player.lives)}\n\nPhase ${boss.phase + 1}/3`,
      });
    });
    function loseLife() {
      player.lives--;
      shake(6);
      swarm.list = []; // clearing the screen after a hit lets you start again fairly
      player.invulnerable = 2;
      if (player.lives <= 0) go("end", false, score, grazes);
    }
  7. Scenes

    There are three scenes (title, game and end) and go() moves between them. Going back to game creates everything again, including an empty bullet swarm.

    function screen(title, subtitle, next) {
      add([text(title, { size: 34, 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, 25)), anchor("center"), color(200, 210, 255),
      ]);
      onButtonPress("start", next);
      onClick(next);
    }
    
    scene("title", () =>
      screen(
        t({ es: "Bullet hell", en: "Bullet hell" }),
        t({
          es: "Flechas o WASD para moverte; disparas solo. Mantén Shift o X para el foco: vas lento y ves tu punto de impacto, lo único que recibe daño.\nCon ratón o dedo, mantén pulsado y la nave te sigue.\nRoza las balas para sumar puntos y derrota las tres fases del jefe.\nPulsa espacio o haz clic para empezar.",
          en: "Arrows or WASD to move; you fire automatically. Hold Shift or X to focus: you move slowly and see your hitbox, the only part that can be hit.\nWith a mouse or finger, hold down and the ship follows you.\nGraze bullets for points and defeat the boss's three phases.\nPress space or click to start.",
        }),
        () => go("game"),
      ),
    );
    
    scene("end", (won, score, grazes) =>
      screen(
        won ? t({ es: "¡Jefe derrotado!", en: "Boss defeated!" }) : t({ es: "Sin vidas", en: "Out of lives" }),
        t({
          es: `Puntos: ${score}   Roces: ${grazes}\nPulsa espacio o haz clic para repetir.`,
          en: `Score: ${score}   Grazes: ${grazes}\nPress space or click to play again.`,
        }),
        () => go("game"),
      ),
    );
    
    go("title");

What we simplified

  • There is only one boss and no stage with waves before it; the shoot 'em up demo already shows that part.
  • There are no bombs for the player to use; instead, losing a life or ending a phase wipes the bullets.
  • Firing is automatic and always the same, with no power-ups or different shot types.

To modify it

  • Add a bomb on a key that wipes every bullet and makes you briefly invulnerable, with a limited number of uses.
  • Create a bullet that speeds up or changes direction halfway (store an acceleration in each bullet).
  • Draw the hitbox outside focus mode too and ask other people whether the game feels fairer or harder.
Full code
// Bullet hell 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: [8, 6, 26],
  buttons: {
    up: { keyboard: ["up", "w"] },
    down: { keyboard: ["down", "s"] },
    left: { keyboard: ["left", "a"] },
    right: { keyboard: ["right", "d"] },
    focus: { keyboard: ["shift", "x"] },
    start: { keyboard: ["space", "enter", "z"] },
  },
});
touchControls(k, [
  { button: "left", label: "◀" },
  { button: "up", label: "▲" },
  { button: "down", label: "▼" },
  { button: "right", label: "▶" },
  { button: "focus", label: "◎", side: "right" },
]);

// A vertical playfield in the middle of the screen, like an upright arcade monitor.
const FIELD = { x: 170, w: 300, h: 360 };
const SPEED = 210; // normal speed: to cross the screen
const FOCUS_SPEED = 80; // focus speed: to thread small gaps
const HITBOX = 2.5; // radius of the only point that can be hit
const GRAZE = 14; // bullets passing this close (without touching) give points
const PINK = rgb(255, 90, 200);
const CYAN = rgb(90, 220, 255);
const GOLD = rgb(255, 210, 80);
// #endregion

// #region bullets
// Hundreds of bullets as game objects (each with area() and collision events) would be slow.
// Here one object, the swarm, keeps every bullet as plain data {x, y, vx, vy, r} and
// moves and draws them all in a single loop. Bullet hell engines work this way.
let swarm;
function addSwarm() {
  swarm = add([z(3), "swarm", {
    list: [],
    update() {
      for (const b of this.list) {
        b.x += b.vx * dt();
        b.y += b.vy * dt();
      }
      this.list = this.list.filter((b) => b.x > FIELD.x - 10 && b.x < FIELD.x + FIELD.w + 10 && b.y > -10 && b.y < FIELD.h + 10);
    },
    draw() {
      for (const b of this.list) drawCircle({ pos: vec2(b.x, b.y), radius: b.r, color: b.col, outline: { width: 1.5, color: WHITE } });
    },
  }]);
}
function shoot(x, y, angle, speed, r = 5, col = PINK) {
  const a = deg2rad(angle);
  swarm.list.push({ x, y, vx: Math.cos(a) * speed, vy: Math.sin(a) * speed, r, col, grazed: false });
}
// #endregion

// #region patterns
// The three classic patterns are pure trigonometry: each bullet is an angle and a speed.
// Ring: n bullets evenly spread around a circle, 360/n degrees apart.
function ring(x, y, n, speed, offset = 0, col = PINK) {
  for (let i = 0; i < n; i++) shoot(x, y, offset + (i * 360) / n, speed, 5, col);
}
// Fan: a few bullets spread around the direction toward the player (atan2 gives that angle).
function fan(x, y, n, spread, speed, target) {
  const aim = rad2deg(Math.atan2(target.y - y, target.x - x));
  for (let i = 0; i < n; i++) shoot(x, y, aim + (i - (n - 1) / 2) * spread, speed, 4, GOLD);
}
// Spiral: a small ring fired again and again, turning a few degrees each time.
// Each bullet flies straight; the spiral only appears in the trail they leave.
function spiral(x, y, arms, angle, speed) {
  for (let i = 0; i < arms; i++) shoot(x, y, angle + (i * 360) / arms, speed, 4, CYAN);
}
// #endregion

scene("game", () => {
  addSwarm();
  let score = 0;
  let grazes = 0;
  // Side panels cover the bullets that leave the playfield.
  add([rect(FIELD.x, 360), color(20, 16, 48), z(8)]);
  add([rect(FIELD.x, 360), color(20, 16, 48), pos(FIELD.x + FIELD.w, 0), z(8)]);
  const hud = add([text("", { size: 15, font: FONT, width: 150 }), pos(12, 14), z(9)]);
  const banner = add([text("", { size: 22, font: FONT }), pos(FIELD.x + FIELD.w / 2, 180), anchor("center"), z(9)]);

  // #region player
  const player = add([
    polygon([vec2(0, -12), vec2(10, 10), vec2(0, 5), vec2(-10, 10)]),
    color(140, 200, 255), pos(FIELD.x + FIELD.w / 2, 320), opacity(1), z(4),
    { lives: 3, invulnerable: 0, cooldown: 0 },
  ]);
  // The hitbox: a dot of 2.5 px that is only drawn while you focus.
  const dot = player.add([circle(HITBOX + 1), color(WHITE), outline(1.5, rgb(255, 40, 80)), opacity(0)]);
  const aura = player.add([circle(GRAZE), color(WHITE), opacity(0)]);
  const touch = matchMedia("(pointer: coarse)").matches;

  player.onUpdate(() => {
    const focus = isButtonDown("focus");
    const speed = focus ? FOCUS_SPEED : SPEED;
    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;
    // Mouse or finger: the ship goes to the pointer (above the finger, so you can see it).
    if (isMouseDown()) dir = mousePos().sub(0, touch ? 50 : 0).sub(player.pos).scale(1 / 12);
    if (dir.len() > 1) dir = dir.unit();
    player.move(dir.scale(speed));
    player.pos.x = clamp(player.pos.x, FIELD.x + 10, FIELD.x + FIELD.w - 10);
    player.pos.y = clamp(player.pos.y, 20, FIELD.h - 12);
    dot.opacity = focus ? 1 : 0;
    aura.opacity = focus ? 0.12 : 0;
    // Shots are automatic: in this genre your hands are busy dodging.
    player.cooldown -= dt();
    if (player.cooldown <= 0) {
      player.cooldown = 0.08;
      add([rect(3, 12), color(200, 255, 200), pos(player.pos.sub(0, 12)), anchor("center"), area(), move(UP, 700), offscreen({ destroy: true }), "shot"]);
    }
    player.invulnerable = Math.max(0, player.invulnerable - dt());
    player.opacity = player.invulnerable > 0 && Math.floor(time() * 12) % 2 ? 0.25 : 1;
  });
  // #endregion

  // #region boss
  // Three phases, each with its own health and pattern. When a phase ends its bullets
  // vanish (a reward and a breather) and the next, harder one begins.
  const PHASES = [
    { name: { es: "Anillos", en: "Rings" }, hp: 50, every: 0.75,
      fire: (b, n) => ring(b.pos.x, b.pos.y, 16, 85, n * 11) },
    { name: { es: "Espiral", en: "Spiral" }, hp: 60, every: 0.11,
      // Every 60 shots the spin reverses, so you have to change direction too.
      fire: (b, n) => spiral(b.pos.x, b.pos.y, 3, (b.spin += Math.floor(n / 60) % 2 ? -9 : 9), 95) },
    { name: { es: "Abanico y anillo", en: "Fan and ring" }, hp: 70, every: 0.55,
      fire: (b, n) => {
        fan(b.pos.x, b.pos.y, 5, 13, 150, player.pos);
        if (n % 3 === 0) ring(b.pos.x, b.pos.y, 22, 65, rand(0, 360));
      } },
  ];
  const boss = add([
    circle(22), color(200, 60, 120), outline(3, WHITE), pos(FIELD.x + FIELD.w / 2, 70), anchor("center"), area(), z(2),
    { phase: 0, hp: PHASES[0].hp, rest: 1.5, timer: 0, shots: 0, spin: 0, age: 0 },
  ]);
  const bar = add([rect(FIELD.w - 20, 5), color(255, 90, 200), pos(FIELD.x + 10, 8), z(6)]);
  boss.onUpdate(() => {
    boss.age += dt();
    boss.pos.x = FIELD.x + FIELD.w / 2 + Math.sin(boss.age * 0.7) * 90;
    const p = PHASES[boss.phase];
    bar.width = ((FIELD.w - 20) * boss.hp) / p.hp;
    boss.rest -= dt(); // a short pause before each phase
    if (boss.rest > 0) return;
    boss.timer -= dt();
    if (boss.timer <= 0) {
      boss.timer = p.every;
      p.fire(boss, boss.shots++);
    }
  });
  boss.onCollide("shot", (s) => {
    destroy(s);
    if (boss.rest > 0) return; // invulnerable between phases
    boss.hp--;
    score += 10;
    if (boss.hp > 0) return;
    score += swarm.list.length * 5; // every cancelled bullet is worth points
    swarm.list = [];
    boss.phase++;
    if (boss.phase === PHASES.length) return go("end", true, score, grazes);
    boss.hp = PHASES[boss.phase].hp;
    boss.shots = 0;
    boss.rest = 1.5;
    banner.text = t(PHASES[boss.phase].name);
    wait(1.4, () => (banner.text = ""));
  });
  banner.text = t(PHASES[0].name);
  wait(1.4, () => (banner.text = ""));
  // #endregion

  // #region hits-graze
  // Collision is just distance: a bullet hits if it is closer than its radius plus the
  // hitbox. A wider ring around it detects "grazes", near misses that earn points.
  onUpdate(() => {
    for (const b of swarm.list) {
      const d = Math.hypot(b.x - player.pos.x, b.y - player.pos.y);
      if (d < b.r + HITBOX && player.invulnerable <= 0) return loseLife();
      if (d < b.r + GRAZE && !b.grazed) {
        b.grazed = true;
        grazes++;
        score += 2;
      }
    }
    hud.text = t({
      es: `Puntos\n${score}\n\nRoces\n${grazes}\n\nVidas\n${"♥".repeat(player.lives)}\n\nFase ${boss.phase + 1}/3`,
      en: `Score\n${score}\n\nGrazes\n${grazes}\n\nLives\n${"♥".repeat(player.lives)}\n\nPhase ${boss.phase + 1}/3`,
    });
  });
  function loseLife() {
    player.lives--;
    shake(6);
    swarm.list = []; // clearing the screen after a hit lets you start again fairly
    player.invulnerable = 2;
    if (player.lives <= 0) go("end", false, score, grazes);
  }
  // #endregion
});

// #region scenes
function screen(title, subtitle, next) {
  add([text(title, { size: 34, 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, 25)), anchor("center"), color(200, 210, 255),
  ]);
  onButtonPress("start", next);
  onClick(next);
}

scene("title", () =>
  screen(
    t({ es: "Bullet hell", en: "Bullet hell" }),
    t({
      es: "Flechas o WASD para moverte; disparas solo. Mantén Shift o X para el foco: vas lento y ves tu punto de impacto, lo único que recibe daño.\nCon ratón o dedo, mantén pulsado y la nave te sigue.\nRoza las balas para sumar puntos y derrota las tres fases del jefe.\nPulsa espacio o haz clic para empezar.",
      en: "Arrows or WASD to move; you fire automatically. Hold Shift or X to focus: you move slowly and see your hitbox, the only part that can be hit.\nWith a mouse or finger, hold down and the ship follows you.\nGraze bullets for points and defeat the boss's three phases.\nPress space or click to start.",
    }),
    () => go("game"),
  ),
);

scene("end", (won, score, grazes) =>
  screen(
    won ? t({ es: "¡Jefe derrotado!", en: "Boss defeated!" }) : t({ es: "Sin vidas", en: "Out of lives" }),
    t({
      es: `Puntos: ${score}   Roces: ${grazes}\nPulsa espacio o haz clic para repetir.`,
      en: `Score: ${score}   Grazes: ${grazes}\nPress space or click to play again.`,
    }),
    () => go("game"),
  ),
);

go("title");
// #endregion

Design challenges

  • Making patterns dense but readable. Enemy bullets use colours and outlines that contrast with the background and are never mistaken for decoration.
  • Making every pattern leave a tight but possible gap, checked against the real hitbox rather than the sprite.
  • Moving and checking hundreds of bullets without slowing the game down, using plain data and distance checks instead of physics.
  • A curve across phases that shows each pattern slow first and fast later, so players learn it before they master it.

History

  1. 1993
    OriginBatsugun

    Batsugun (Toaplan, 1993) introduced increasingly complex bullet patterns and a very small hitbox to keep them fair.

    Bullet hell (Wikipedia)
  2. 1995
    DonPachi

    Cave's DonPachi adds a combo system that rewards destroying enemies quickly.

    Bullet hell (Wikipedia)
  3. 1997

    Story of Eastern Wonderland, the second Touhou Project game, is the first in the series to be a bullet hell.

    Bullet hell (Wikipedia)
  4. 2001
    Ikaruga

    Ikaruga's GameCube release draws attention to the genre outside Japan.

    Bullet hell (Wikipedia)
  5. 2002
    Touhou Koumakyou ~ the Embodiment of Scarlet Devil

    Embodiment of Scarlet Devil, the first Touhou game for Windows, introduces the bosses' spell cards and has a focus mode for slower dodging and points for grazing bullets.

    Embodiment of Scarlet Devil (Wikipedia)
  6. 2015
    Undertale

    Undertale brings bullet hell into a role-playing game: in each battle you dodge the enemy's attacks by moving a heart.

    Undertale (Wikipedia)
  7. 2016
    Enter the Gungeon

    Enter the Gungeon blends bullet hell with the roguelike, in top-down rooms you move through freely.

    Enter the Gungeon (Wikipedia)
  8. 2022

    Vampire Survivors popularises «bullet heaven», which turns the genre around: now it is your character who fills the screen with bullets.

    Bullet hell (Wikipedia)

Reference games

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.

1995

DonPachi

Cave. arcade

A vertically scrolling shoot 'em up with which Cave starts its line of bullet hell games. A combo counter rewards chaining enemy kills without a pause.

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.

2002

Touhou Koumakyou ~ the Embodiment of Scarlet Devil

Team Shanghai Alice. Windows

The sixth Touhou Project game and the first for Windows, the work of a one-person circle. It introduces the bosses' «spell cards», and has a focus mode for slower dodging and points for grazing bullets.

2015

Undertale

Toby Fox. Windows, macOS

A role-playing game in which each battle mixes turns with a small bullet hell, where you dodge attacks by moving a heart that stands for your soul. You can also spare monsters instead of defeating them.

2016

Enter the Gungeon

Dodge Roll. Windows, macOS, Linux, PlayStation 4

A top-down bullet hell with randomly generated rooms. You dodge with a roll that makes you briefly invulnerable, and «blanks» wipe every projectile in the room.

2024

NEHONOBASA

nizakashii. navegador, Windows

An independent bullet hell with four stages and a final boss. Holding fire slows the ship for precise dodging, and bombs that collide with each other clear the screen. Its author publishes it free and playable in the browser.

Exercise

In the demo, change how many bullets the ring has and how much the spiral turns on each shot, and find the point where the pattern no longer has a gap. Then design a fourth phase that combines two patterns and ask someone to play it with and without focus.

Sources