
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 screenAn 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
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); }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; }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) }) }]);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(); });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(); });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(); } });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 }); } }, }, ]);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");
// #endregionDesign 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
- 1975OriginGun 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) - 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) - 1982Robotron: 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) - 1990Smash TV
Eugene Jarvis's Smash TV brings back Robotron's twin sticks in closed rooms and for two players.
Smash TV (Wikipedia) - 2005Geometry Wars: Retro Evolved
Geometry Wars: Retro Evolved revives the genre thanks to gamepads with two analogue sticks.
Twin-stick shooter (Wikipedia) - 2009Minigore
Minigore brings twin-stick play to iOS and the touch screen, winning critical acclaim and a cult following.
Twin-stick shooter (Wikipedia) - 2011The 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
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
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
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
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
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
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
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.
2021
Solar Defence
You defend a solar system from waves of alien ships, moving with WASD and aiming and firing with the mouse; on a touch screen, the left half moves and the right half fires. It was made for js13kGames 2021, 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.