
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 screenA 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
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);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 }); }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); }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; });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 = ""));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); }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");
// #endregionDesign 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
- 1993OriginBatsugun
Batsugun (Toaplan, 1993) introduced increasingly complex bullet patterns and a very small hitbox to keep them fair.
Bullet hell (Wikipedia) - 1995DonPachi
Cave's DonPachi adds a combo system that rewards destroying enemies quickly.
Bullet hell (Wikipedia) - 1997
Story of Eastern Wonderland, the second Touhou Project game, is the first in the series to be a bullet hell.
Bullet hell (Wikipedia) - 2001Ikaruga
Ikaruga's GameCube release draws attention to the genre outside Japan.
Bullet hell (Wikipedia) - 2002Touhou 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) - 2015Undertale
Undertale brings bullet hell into a role-playing game: in each battle you dodge the enemy's attacks by moving a heart.
Undertale (Wikipedia) - 2016Enter the Gungeon
Enter the Gungeon blends bullet hell with the roguelike, in top-down rooms you move through freely.
Enter the Gungeon (Wikipedia) - 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
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
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
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
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
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
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
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.