Skip to content
GameGenresEspañol
Shooter

Twin-stick shooter

One stick moves your character and the other fires in any direction, regardless of where you walk, while you survive surrounding hordes seen from above.

Also known as: dual-stick shooter

Defining mechanics

  • Two independent controls, one to move and one to aim and fire. On PC they are usually the keyboard and the mouse, and on mobile, two virtual sticks.
  • Firing in any direction, often automatic while aiming, which lets you back away while shooting forwards.
  • An arena or closed rooms seen from above, with enemies arriving from every side.
  • Waves of enemies with different behaviours, such as chasing, charging, circling or shooting from afar, combined to apply pressure.
  • Score, lives and sometimes weapon upgrades that reward surviving longer.

Gameplay loop

Move to open up space while aiming at the most urgent threat, clear the wave and get ready for the next one, which arrives bigger.

Our demo

Controls. WASD to move. Aim with the mouse and hold the button to fire, or fire with the arrow keys. On a touch screen, drag on the left half to move and on the right half to fire.

Open full screen

An arena seen from above where you move with one control and aim with another, while waves of enemies arrive with three different behaviours (chasing, charging and shooting from afar). You score points for each enemy and have three lives.

Made with Kaplay, in about 200 lines of JavaScript.

How it's made

  1. Setup and buttons

    There are two groups of virtual buttons: WASD to move and the arrow keys to aim. The arrows work as a second «stick» on the keyboard, just like the second joystick of Robotron: 2084. touchToMouse is turned off because on a touch screen each finger controls a different stick and cannot be turned into a single pointer.

    const k = kaplay({
      width: 640,
      height: 360,
      letterbox: true, pixelDensity,
      background: [20, 22, 40],
      touchToMouse: false, // we read each finger separately (one per stick)
      buttons: {
        left: { keyboard: ["a"] },
        right: { keyboard: ["d"] },
        up: { keyboard: ["w"] },
        down: { keyboard: ["s"] },
        // The arrow keys are a second "stick" on the keyboard: they aim and fire.
        aimLeft: { keyboard: ["left"] },
        aimRight: { keyboard: ["right"] },
        aimUp: { keyboard: ["up"] },
        aimDown: { keyboard: ["down"] },
        start: { keyboard: ["space", "enter"] },
      },
    });
    
    const SPEED = 170;
    const BULLET_SPEED = 480;
    const FIRE_DELAY = 0.12; // seconds between shots
    const STICK_RADIUS = 40; // how far a finger must drag to push a virtual stick fully
    const ARENA = { x: 20, y: 36, w: 600, h: 310 };
    function keepInArena(obj) {
      obj.pos.x = clamp(obj.pos.x, ARENA.x + 10, ARENA.x + ARENA.w - 10);
      obj.pos.y = clamp(obj.pos.y, ARENA.y + 10, ARENA.y + ARENA.h - 10);
    }
  2. Two independent vectors: moving and aiming

    This is the idea that defines the genre. moveVector() combines the keyboard and the left stick into a vector that says where to walk, and clamps it to length 1 so that moving diagonally is not faster. aimVector() returns another vector, of length 1, that says where to fire: the arrow keys, the right stick or, with the mouse, the cursor position minus the player position. Because they are two separate vectors, you can back away while firing forwards or circle an enemy while still aiming at it. A touch stick is worth the drag from where the finger landed, divided by its radius.

    // Two independent vectors: where you MOVE and where you AIM.
    const axis = (a, b) => (isButtonDown(b) ? 1 : 0) - (isButtonDown(a) ? 1 : 0);
    // Touch: a finger on the left half is the move stick, on the right half the aim stick.
    // (Called from the game scene: go() clears the input events of the previous scene.)
    const sticks = { move: null, aim: null };
    function listenToSticks() {
      sticks.move = sticks.aim = null;
      onTouchStart((p, touch) => {
        const side = p.x < width() / 2 ? "move" : "aim";
        sticks[side] = { id: touch.identifier, origin: p, now: p };
      });
      onTouchMove((p, touch) => {
        for (const s of Object.values(sticks)) if (s && s.id === touch.identifier) s.now = p;
      });
      onTouchEnd((_, touch) => {
        for (const side of ["move", "aim"]) if (sticks[side]?.id === touch.identifier) sticks[side] = null;
      });
    }
    // A stick's value is the drag from where the finger landed, at most length 1.
    const stickVec = (s) => (s ? s.now.sub(s.origin).scale(1 / STICK_RADIUS) : vec2(0));
    const clampLen = (v) => (v.len() > 1 ? v.unit() : v);
    
    function moveVector() {
      return clampLen(vec2(axis("left", "right"), axis("up", "down")).add(stickVec(sticks.move)));
    }
    // Returns a unit vector to fire along, or null if the player is not aiming.
    function aimVector(player) {
      const keys = vec2(axis("aimLeft", "aimRight"), axis("aimUp", "aimDown"));
      if (keys.len() > 0) return keys.unit();
      const s = stickVec(sticks.aim);
      if (s.len() > 0.25) return s.unit();
      if (isMouseDown("left")) return mousePos().sub(player.pos).unit();
      return null;
    }
  3. The player and firing

    Each frame the player advances along the movement vector and, separately, stores the aim vector. If you are aiming and the weapon has cooled down (FIRE_DELAY), a bullet appears at the tip of the barrel with the move() component, which keeps it travelling in that direction. The white line draws the aim vector, so you can see it does not depend on where you walk. After a hit, the player blinks while invulnerable.

    const player = add([circle(10), pos(320, 190), color(120, 220, 255), opacity(1), area(), anchor("center"), z(5), { aim: vec2(1, 0) }]);
    let cooldown = 0;
    player.onUpdate(() => {
      // 1) Movement: the left stick (or WASD) moves, whatever the aim.
      player.move(moveVector().scale(SPEED));
      keepInArena(player);
      // 2) Aiming: a separate vector; with the mouse it always points at the cursor.
      const aim = aimVector(player);
      if (aim) player.aim = aim;
      else if (!sticks.aim) player.aim = mousePos().sub(player.pos).unit();
      cooldown -= dt();
      if (aim && cooldown <= 0) {
        cooldown = FIRE_DELAY;
        add([circle(3), pos(player.pos.add(aim.scale(14))), color(255, 240, 150), area(), anchor("center"), move(aim, BULLET_SPEED), offscreen({ destroy: true }), "bullet"]);
      }
      invulnerable -= dt();
      player.opacity = invulnerable > 0 && Math.floor(time() * 12) % 2 ? 0.3 : 1;
    });
    // The barrel shows the aim vector, so you can see it is independent of movement.
    add([z(6), { draw: () => drawLine({ p1: player.pos, p2: player.pos.add(player.aim.scale(20)), width: 4, color: rgb(255, 255, 255) }) }]);
  4. Three chasing behaviours

    Each enemy type is a different rule for choosing its velocity, and mixing them forces you to move and aim at the same time. The chaser always walks straight at you, like Robotron's Grunts. The dasher stops, takes aim and charges in a straight line, so sidestepping is the answer. The shooter keeps its distance, circles you using the perpendicular vector (normal()) and fires slow bullets. Each one is worth more points the harder it is.

    // Three behaviours, each a different rule for choosing a velocity.
    const KINDS = {
      chaser: { points: 10, color: rgb(230, 80, 80), shape: () => rect(18, 18) },
      dasher: { points: 20, color: rgb(255, 160, 60), shape: () => polygon([vec2(-11, -11), vec2(11, 0), vec2(-11, 11)]) },
      shooter: { points: 30, color: rgb(190, 110, 255), shape: () => circle(11) },
    };
    function spawnEnemy(kind) {
      // Spawn on an edge of the arena, never right next to the player.
      let p;
      do p = vec2(rand(ARENA.x + 15, ARENA.x + ARENA.w - 15), choose([ARENA.y + 15, ARENA.y + ARENA.h - 15]));
      while (p.dist(player.pos) < 160);
      add([KINDS[kind].shape(), pos(p), color(KINDS[kind].color), area(), anchor("center"), rotate(0), "enemy", { kind, timer: rand(0.5, 1.5), vel: vec2(0) }]);
    }
    onUpdate("enemy", (e) => {
      const toPlayer = player.pos.sub(e.pos);
      e.timer -= dt();
      if (e.kind === "chaser") {
        // Chaser: always walks straight at you.
        e.move(toPlayer.unit().scale(70 + wave * 5));
      } else if (e.kind === "dasher") {
        // Dasher: stops, takes aim, then charges in a straight line.
        if (e.timer <= 0) {
          e.vel = toPlayer.unit().scale(320);
          e.timer = 1.6;
        }
        if (e.timer < 1.1) e.vel = e.vel.scale(0.94); // brakes after the dash
        e.move(e.vel);
        e.angle = toPlayer.angle();
      } else {
        // Shooter: keeps its distance, circles around you and fires slow bullets.
        const keepAway = toPlayer.len() < 170 ? -1 : 1;
        e.move(toPlayer.unit().scale(60 * keepAway).add(toPlayer.normal().unit().scale(50)));
        if (e.timer <= 0) {
          e.timer = 2;
          add([circle(5), pos(e.pos), color(220, 140, 255), area(), anchor("center"), move(toPlayer.unit(), 140), offscreen({ destroy: true }), "enemyBullet"]);
        }
      }
      keepInArena(e);
    });
    onCollide("bullet", "enemy", (b, e) => {
      destroy(b);
      destroy(e);
      score += KINDS[e.kind].points;
      updateHud();
    });
  5. Lives and invulnerability

    Touching an enemy or an enemy bullet costs a life and grants 1.5 seconds of invulnerability, so the same group cannot take several lives at once. The screen shakes so the hit is felt. With no lives left, the game ends.

    function hurt() {
      if (invulnerable > 0) return;
      lives--;
      invulnerable = 1.5;
      shake(6);
      updateHud();
      if (lives <= 0) go("end", score, wave);
    }
    player.onCollideUpdate("enemy", hurt); // every frame while touching
    player.onCollide("enemyBullet", (b) => {
      destroy(b);
      hurt();
    });
  6. Waves

    Each wave brings more enemies (3 + 2 per wave) and adds a new behaviour. The first has only chasers, the second brings dashers and from the third on, shooters too. Enemies appear one after another on the edges and never next to the player, and the next wave starts when none are left.

    // Each wave is bigger and adds a new behaviour: chasers, then dashers, then shooters.
    const banner = add([text("", { size: 26, font: FONT }), pos(center()), anchor("center"), z(10)]);
    function nextWave() {
      wave++;
      updateHud();
      banner.text = t({ es: `Oleada ${wave}`, en: `Wave ${wave}` });
      wait(1.2, () => {
        banner.text = "";
        for (let i = 0; i < 3 + wave * 2; i++) {
          const kinds = ["chaser", "dasher", "shooter"].slice(0, Math.min(3, wave));
          wait(i * 0.25, () => spawnEnemy(choose(kinds)));
        }
        wait((3 + wave * 2) * 0.25 + 0.5, () => (waiting = false));
      });
    }
    let waiting = true;
    nextWave();
    onUpdate(() => {
      if (!waiting && get("enemy").length === 0) {
        waiting = true;
        nextWave();
      }
    });
  7. Virtual sticks on touch screens

    Touch screens have no physical sticks, so the stick appears wherever you put your finger down. The first finger on the left half moves and the one on the right half aims; each is recognised by its identifier. A base circle and a knob that follows the finger up to the edge are drawn.

    // Draw each virtual stick where the finger landed.
    add([
      z(9),
      {
        draw() {
          for (const s of Object.values(sticks)) {
            if (!s) continue;
            drawCircle({ pos: s.origin, radius: STICK_RADIUS, color: rgb(255, 255, 255), opacity: 0.15 });
            drawCircle({ pos: s.origin.add(clampLen(stickVec(s)).scale(STICK_RADIUS)), radius: 16, color: rgb(255, 255, 255), opacity: 0.4 });
          }
        },
      },
    ]);
  8. Scenes

    Three scenes (title, game and end) with go(). The end screen shows the score and the wave reached, the genre's two measures of progress.

    function screen(title, subtitle, next) {
      add([text(title, { size: 32, font: FONT, align: "center" }), pos(center().sub(0, 50)), anchor("center")]);
      add([text(subtitle, { size: 15, font: FONT, align: "center", width: 560 }), pos(center().add(0, 30)), anchor("center"), color(200, 210, 255)]);
      onButtonPress("start", next);
      onClick(next);
      onTouchStart(next);
    }
    
    scene("title", () =>
      screen(
        t({ es: "Disparos con doble stick", en: "Twin-stick shooter" }),
        t({
          es: "WASD para moverte. Apunta con el ratón y mantén el clic para disparar (o dispara con las flechas).\nEn pantalla táctil, arrastra en la mitad izquierda para moverte y en la derecha para disparar.\nPulsa espacio o toca para empezar.",
          en: "WASD to move. Aim with the mouse and hold the button to fire (or fire with the arrow keys).\nOn a touch screen, drag on the left half to move and on the right half to fire.\nPress space or tap to start.",
        }),
        () => go("game"),
      ),
    );
    
    scene("end", (score, wave) =>
      screen(
        t({ es: "Fin de la partida", en: "Game over" }),
        t({
          es: `Puntos: ${score} · Llegaste a la oleada ${wave}.\nPulsa espacio o toca para repetir.`,
          en: `Score: ${score} · You reached wave ${wave}.\nPress space or tap to play again.`,
        }),
        () => go("game"),
      ),
    );
    
    go("title");

What we simplified

  • A single rapid-fire weapon, with no upgrades, bombs or reloading.
  • The arena is an empty rectangle; there are no obstacles or cover to change the enemies' paths.
  • Waves spawn at random on the edges and grow endlessly, instead of being designed by hand.
  • Enemies can overlap each other; they do not keep apart.

To modify it

  • Add a fourth enemy that splits into two smaller ones when shot.
  • Try aiming with the same vector as movement (fire where you walk) and compare how the game changes. Why does the genre keep the two vectors apart?
  • Put two or three obstacles in the arena that block bullets and force enemies to go around them.
  • Make enemies keep apart by adding to their velocity a small push away from their neighbours.
Full code
// Twin-stick shooter demo
// Engine: Kaplay 3001 (https://kaplayjs.com). MIT licence.
import kaplay from "../../vendor/kaplay.mjs";
import { t, FONT, pixelDensity } from "../common.js";

// #region setup
const k = kaplay({
  width: 640,
  height: 360,
  letterbox: true, pixelDensity,
  background: [20, 22, 40],
  touchToMouse: false, // we read each finger separately (one per stick)
  buttons: {
    left: { keyboard: ["a"] },
    right: { keyboard: ["d"] },
    up: { keyboard: ["w"] },
    down: { keyboard: ["s"] },
    // The arrow keys are a second "stick" on the keyboard: they aim and fire.
    aimLeft: { keyboard: ["left"] },
    aimRight: { keyboard: ["right"] },
    aimUp: { keyboard: ["up"] },
    aimDown: { keyboard: ["down"] },
    start: { keyboard: ["space", "enter"] },
  },
});

const SPEED = 170;
const BULLET_SPEED = 480;
const FIRE_DELAY = 0.12; // seconds between shots
const STICK_RADIUS = 40; // how far a finger must drag to push a virtual stick fully
const ARENA = { x: 20, y: 36, w: 600, h: 310 };
function keepInArena(obj) {
  obj.pos.x = clamp(obj.pos.x, ARENA.x + 10, ARENA.x + ARENA.w - 10);
  obj.pos.y = clamp(obj.pos.y, ARENA.y + 10, ARENA.y + ARENA.h - 10);
}
// #endregion

// #region input
// Two independent vectors: where you MOVE and where you AIM.
const axis = (a, b) => (isButtonDown(b) ? 1 : 0) - (isButtonDown(a) ? 1 : 0);
// Touch: a finger on the left half is the move stick, on the right half the aim stick.
// (Called from the game scene: go() clears the input events of the previous scene.)
const sticks = { move: null, aim: null };
function listenToSticks() {
  sticks.move = sticks.aim = null;
  onTouchStart((p, touch) => {
    const side = p.x < width() / 2 ? "move" : "aim";
    sticks[side] = { id: touch.identifier, origin: p, now: p };
  });
  onTouchMove((p, touch) => {
    for (const s of Object.values(sticks)) if (s && s.id === touch.identifier) s.now = p;
  });
  onTouchEnd((_, touch) => {
    for (const side of ["move", "aim"]) if (sticks[side]?.id === touch.identifier) sticks[side] = null;
  });
}
// A stick's value is the drag from where the finger landed, at most length 1.
const stickVec = (s) => (s ? s.now.sub(s.origin).scale(1 / STICK_RADIUS) : vec2(0));
const clampLen = (v) => (v.len() > 1 ? v.unit() : v);

function moveVector() {
  return clampLen(vec2(axis("left", "right"), axis("up", "down")).add(stickVec(sticks.move)));
}
// Returns a unit vector to fire along, or null if the player is not aiming.
function aimVector(player) {
  const keys = vec2(axis("aimLeft", "aimRight"), axis("aimUp", "aimDown"));
  if (keys.len() > 0) return keys.unit();
  const s = stickVec(sticks.aim);
  if (s.len() > 0.25) return s.unit();
  if (isMouseDown("left")) return mousePos().sub(player.pos).unit();
  return null;
}
// #endregion

scene("game", () => {
  listenToSticks();
  let score = 0;
  let lives = 3;
  let wave = 0;
  let invulnerable = 0;
  const hud = add([text("", { size: 15, font: FONT }), pos(20, 10), z(10)]);
  const updateHud = () =>
    (hud.text = t({ es: `Puntos: ${score}   Vidas: ${lives}   Oleada: ${wave}`, en: `Score: ${score}   Lives: ${lives}   Wave: ${wave}` }));
  add([rect(ARENA.w, ARENA.h), pos(ARENA.x, ARENA.y), color(32, 36, 62), outline(2, rgb(90, 100, 160))]);

  // #region player
  const player = add([circle(10), pos(320, 190), color(120, 220, 255), opacity(1), area(), anchor("center"), z(5), { aim: vec2(1, 0) }]);
  let cooldown = 0;
  player.onUpdate(() => {
    // 1) Movement: the left stick (or WASD) moves, whatever the aim.
    player.move(moveVector().scale(SPEED));
    keepInArena(player);
    // 2) Aiming: a separate vector; with the mouse it always points at the cursor.
    const aim = aimVector(player);
    if (aim) player.aim = aim;
    else if (!sticks.aim) player.aim = mousePos().sub(player.pos).unit();
    cooldown -= dt();
    if (aim && cooldown <= 0) {
      cooldown = FIRE_DELAY;
      add([circle(3), pos(player.pos.add(aim.scale(14))), color(255, 240, 150), area(), anchor("center"), move(aim, BULLET_SPEED), offscreen({ destroy: true }), "bullet"]);
    }
    invulnerable -= dt();
    player.opacity = invulnerable > 0 && Math.floor(time() * 12) % 2 ? 0.3 : 1;
  });
  // The barrel shows the aim vector, so you can see it is independent of movement.
  add([z(6), { draw: () => drawLine({ p1: player.pos, p2: player.pos.add(player.aim.scale(20)), width: 4, color: rgb(255, 255, 255) }) }]);
  // #endregion

  // #region enemies
  // Three behaviours, each a different rule for choosing a velocity.
  const KINDS = {
    chaser: { points: 10, color: rgb(230, 80, 80), shape: () => rect(18, 18) },
    dasher: { points: 20, color: rgb(255, 160, 60), shape: () => polygon([vec2(-11, -11), vec2(11, 0), vec2(-11, 11)]) },
    shooter: { points: 30, color: rgb(190, 110, 255), shape: () => circle(11) },
  };
  function spawnEnemy(kind) {
    // Spawn on an edge of the arena, never right next to the player.
    let p;
    do p = vec2(rand(ARENA.x + 15, ARENA.x + ARENA.w - 15), choose([ARENA.y + 15, ARENA.y + ARENA.h - 15]));
    while (p.dist(player.pos) < 160);
    add([KINDS[kind].shape(), pos(p), color(KINDS[kind].color), area(), anchor("center"), rotate(0), "enemy", { kind, timer: rand(0.5, 1.5), vel: vec2(0) }]);
  }
  onUpdate("enemy", (e) => {
    const toPlayer = player.pos.sub(e.pos);
    e.timer -= dt();
    if (e.kind === "chaser") {
      // Chaser: always walks straight at you.
      e.move(toPlayer.unit().scale(70 + wave * 5));
    } else if (e.kind === "dasher") {
      // Dasher: stops, takes aim, then charges in a straight line.
      if (e.timer <= 0) {
        e.vel = toPlayer.unit().scale(320);
        e.timer = 1.6;
      }
      if (e.timer < 1.1) e.vel = e.vel.scale(0.94); // brakes after the dash
      e.move(e.vel);
      e.angle = toPlayer.angle();
    } else {
      // Shooter: keeps its distance, circles around you and fires slow bullets.
      const keepAway = toPlayer.len() < 170 ? -1 : 1;
      e.move(toPlayer.unit().scale(60 * keepAway).add(toPlayer.normal().unit().scale(50)));
      if (e.timer <= 0) {
        e.timer = 2;
        add([circle(5), pos(e.pos), color(220, 140, 255), area(), anchor("center"), move(toPlayer.unit(), 140), offscreen({ destroy: true }), "enemyBullet"]);
      }
    }
    keepInArena(e);
  });
  onCollide("bullet", "enemy", (b, e) => {
    destroy(b);
    destroy(e);
    score += KINDS[e.kind].points;
    updateHud();
  });
  // #endregion

  // #region damage
  function hurt() {
    if (invulnerable > 0) return;
    lives--;
    invulnerable = 1.5;
    shake(6);
    updateHud();
    if (lives <= 0) go("end", score, wave);
  }
  player.onCollideUpdate("enemy", hurt); // every frame while touching
  player.onCollide("enemyBullet", (b) => {
    destroy(b);
    hurt();
  });
  // #endregion

  // #region waves
  // Each wave is bigger and adds a new behaviour: chasers, then dashers, then shooters.
  const banner = add([text("", { size: 26, font: FONT }), pos(center()), anchor("center"), z(10)]);
  function nextWave() {
    wave++;
    updateHud();
    banner.text = t({ es: `Oleada ${wave}`, en: `Wave ${wave}` });
    wait(1.2, () => {
      banner.text = "";
      for (let i = 0; i < 3 + wave * 2; i++) {
        const kinds = ["chaser", "dasher", "shooter"].slice(0, Math.min(3, wave));
        wait(i * 0.25, () => spawnEnemy(choose(kinds)));
      }
      wait((3 + wave * 2) * 0.25 + 0.5, () => (waiting = false));
    });
  }
  let waiting = true;
  nextWave();
  onUpdate(() => {
    if (!waiting && get("enemy").length === 0) {
      waiting = true;
      nextWave();
    }
  });
  // #endregion

  // #region touch-sticks
  // Draw each virtual stick where the finger landed.
  add([
    z(9),
    {
      draw() {
        for (const s of Object.values(sticks)) {
          if (!s) continue;
          drawCircle({ pos: s.origin, radius: STICK_RADIUS, color: rgb(255, 255, 255), opacity: 0.15 });
          drawCircle({ pos: s.origin.add(clampLen(stickVec(s)).scale(STICK_RADIUS)), radius: 16, color: rgb(255, 255, 255), opacity: 0.4 });
        }
      },
    },
  ]);
  // #endregion
});

// #region scenes
function screen(title, subtitle, next) {
  add([text(title, { size: 32, font: FONT, align: "center" }), pos(center().sub(0, 50)), anchor("center")]);
  add([text(subtitle, { size: 15, font: FONT, align: "center", width: 560 }), pos(center().add(0, 30)), anchor("center"), color(200, 210, 255)]);
  onButtonPress("start", next);
  onClick(next);
  onTouchStart(next);
}

scene("title", () =>
  screen(
    t({ es: "Disparos con doble stick", en: "Twin-stick shooter" }),
    t({
      es: "WASD para moverte. Apunta con el ratón y mantén el clic para disparar (o dispara con las flechas).\nEn pantalla táctil, arrastra en la mitad izquierda para moverte y en la derecha para disparar.\nPulsa espacio o toca para empezar.",
      en: "WASD to move. Aim with the mouse and hold the button to fire (or fire with the arrow keys).\nOn a touch screen, drag on the left half to move and on the right half to fire.\nPress space or tap to start.",
    }),
    () => go("game"),
  ),
);

scene("end", (score, wave) =>
  screen(
    t({ es: "Fin de la partida", en: "Game over" }),
    t({
      es: `Puntos: ${score} · Llegaste a la oleada ${wave}.\nPulsa espacio o toca para repetir.`,
      en: `Score: ${score} · You reached wave ${wave}.\nPress space or tap to play again.`,
    }),
    () => go("game"),
  ),
);

go("title");
// #endregion

Design challenges

  • Making movement and aiming feel equally precise with a gamepad, with keyboard and mouse and with touch, three very different control schemes.
  • Designing enemy behaviours that read at a glance and force both hands to work together, instead of just backing away while firing.
  • Keeping a screen full of enemies, bullets and effects readable.
  • Pacing waves to alternate pressure and rest, with spawns that warn first so no hit feels unfair.

History

  1. 1975
    OriginGun Fight

    Gun Fight (1975) already used one joystick to move and another to shoot, but the scheme became famous with Robotron: 2084 (1982).

    Twin-stick shooter (Wikipedia)
  2. 1979

    Nintendo's Sheriff separates movement (with an eight-way joystick) from aiming (with a dial that also covers eight directions).

    Twin-stick shooter (Wikipedia)
  3. 1982
    Robotron: 2084

    Robotron: 2084, by Eugene Jarvis and Larry DeMar, becomes the genre's seminal example and popularises twin joysticks among shooting games.

    Robotron: 2084 (Wikipedia)
  4. 1990
    Smash TV

    Eugene Jarvis's Smash TV brings back Robotron's twin sticks in closed rooms and for two players.

    Smash TV (Wikipedia)
  5. 2005
    Geometry Wars: Retro Evolved

    Geometry Wars: Retro Evolved revives the genre thanks to gamepads with two analogue sticks.

    Twin-stick shooter (Wikipedia)
  6. 2009
    Minigore

    Minigore brings twin-stick play to iOS and the touch screen, winning critical acclaim and a cult following.

    Twin-stick shooter (Wikipedia)
  7. 2011
    The Binding of Isaac

    The Binding of Isaac combines twin-stick-style shooting with the random rooms and permanent death of roguelikes.

    The Binding of Isaac (Wikipedia)

Reference games

1975

Gun Fight

Midway (sobre Western Gun de Tomohiro Nishikado, Taito). arcade

A duel between two gunfighters, each with two controls, one to move and one to choose where to fire, while they take cover behind obstacles. It was the first video game to depict combat between people.

1982

Robotron: 2084

Eugene Jarvis y Larry DeMar (Vid Kidz, para Williams Electronics). arcade

The left joystick moves you and the right one fires in eight directions at endless waves of robots, each type with its own behaviour, while you rescue the humans left on screen.

1990

Smash TV

Williams Electronics (Eugene Jarvis y Mark Turmell). arcade, SNES, Mega Drive

Room after room of a TV game show, one or two players move with one joystick and fire with the other at crowds pouring in through every door. It revives the controls of Robotron: 2084, by the same designer.

2005

Geometry Wars: Retro Evolved

Bizarre Creations. Xbox 360, Windows

A ship in a closed arena of neon vector graphics moves with one analogue stick and fires with the other at ever denser swarms of geometric shapes. It began as a minigame in Project Gotham Racing 2 (2003).

2009

Minigore

Mountain Sheep. iOS

You survive hordes of monsters in a forest, moving with one stick and firing with the other, both on the phone's touch screen.

2011

The Binding of Isaac

Edmund McMillen y Florian Himsl. Windows

You shoot monsters room by room in basements assembled at random from pre-built rooms. Losing all your hearts ends the run and you start in a new basement; the items you find change how you play each time.

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 first with keyboard and mouse, then with the keyboard alone (WASD and arrows). Then modify it so it always fires where you walk and compare the two versions. Use a vector drawing to explain why separating movement and aiming changes what the player can do.

Sources