// now available

This game is one chapter of a book. Smarter Than the 80s — A History of Artificial Intelligence in Games, and How to Build It Better takes the same approach across twelve chapters: build the brain of an arcade defender, a stealth guard, a swarming horde, a living economy and a learning opponent — in plain JavaScript you can type in and run, with no experience required. Read it on Amazon ↗

Watch it learn you

Before any code, play a round. Use the arrow keys to move and space to fire — but the part to actually watch is the panel underneath the game. The teal squares are a live map of where you spend your time. The difficulty number drifts as you cope or struggle. And the four coloured bars are the director working out which kind of attack troubles you specifically.

Trouble with the embed? Open it full screen ↗  ·  press TAB to toggle the AI overlay

Try this: camp in one corner and hold still. Within a few waves you'll notice the volleys start arriving exactly where you're sitting, and pressure building from the side you've been avoiding. Now keep moving instead — the director shifts toward sweeping arcs and spirals that punish standing still. It isn't scripted to do either. It worked out what bothers you and leaned into it.

What "enemy AI" meant in 1981

The arcade classics we think of as having clever enemies almost never did, in any learning sense. Their brilliance was in authored motion, not adaptation.

// fixed-pattern (Galaxian, Galaga)

Enemies follow pre-authored flight paths stored as data. The same input frame produces the same dive every time. Difficulty comes from speed and volume, not decisions. The game has no concept of who's holding the joystick.

// adaptive director (this)

Enemies are composed at runtime from a model of the player. The same screen produces different attacks depending on how you've been playing. Difficulty is a control loop. The game has a representation of you.

None of this needs a neural network, and deliberately doesn't — a black box would defeat the point of a tutorial. The whole director is a few hundred lines of plain arithmetic you can read, reason about, and watch operate live. It's built from four small parts.

The player model: a map of your habits

Everything starts with noticing where the player lives. The screen is divided into a coarse grid and we add weight to whichever cell the ship is in, a few times a second. Crucially, every cell decays slightly on each observation — so the model reflects recent habit, and follows you when you change tactics rather than remembering forever.

shmup.html — the player model
const playerModel = {
  grid: new Array(PM_COLS * PM_ROWS).fill(0),
  observe(nx, ny) {           // nx, ny are normalised 0..1
    // decay everything first, so old habits fade
    for (let i = 0; i < this.grid.length; i++) this.grid[i] *= PM_DECAY;
    const cx = clamp(Math.floor(nx * PM_COLS), 0, PM_COLS - 1);
    const cy = clamp(Math.floor(ny * PM_ROWS), 0, PM_ROWS - 1);
    this.grid[cy * PM_COLS + cx] += 1;
  },
  coldestCol() {  // the column the player AVOIDS — aim here to force movement
    const colSum = new Array(PM_COLS).fill(0);
    for (let i = 0; i < this.grid.length; i++) colSum[i % PM_COLS] += this.grid[i];
    let best = 0;
    for (let i = 1; i < PM_COLS; i++) if (colSum[i] < colSum[best]) best = i;
    return best;
  }
};

That coldestCol() method is the first sign of intent. A fixed-pattern game fires where it always fires. This one can ask "where is the player refusing to go?" and deliberately apply pressure there, to dislodge them from a safe spot. The heat overlay you see in-game is just this grid drawn back onto the canvas — the faint red column is the cold one being targeted.

The governor: a thermostat for difficulty

Dynamic difficulty adjustment has a bad reputation from games that did it clumsily — "rubber-banding" that feels like cheating. Done honestly it's just a thermostat. We define a performance signal between 0 and 1, pick a target (here 0.55 — challenged but coping), and nudge difficulty toward closing the gap. It's bounded, it's smooth, and it never lurches.

shmup.html — the DDA governor
const governor = {
  difficulty: 1.0, target: 0.55, min: 0.45, max: 2.2, step: 0.05,
  tick(perf) {
    // perf above target → player coping → harder. Below → ease off.
    this.difficulty = clamp(
      this.difficulty + (perf - this.target) * this.step,
      this.min, this.max
    );
    return this.difficulty;
  }
};

The difficulty value feeds straight into the pattern generator: more shots, faster, with tighter wave timing. The governor doesn't decide what the enemies do — only how hard they lean. That separation is what keeps it from feeling like cheating: the game gets more intense, not more unfair.

// a real bug this caught

The performance signal is wired so that getting hit pushes it down and surviving while being pressured pushes it up. An early version only re-evaluated the loop under a condition that, in practice, almost never held during fast play — so the difficulty silently froze. It was invisible by eye; it only showed up because the whole thing is tested headlessly, running thousands of simulated frames and asserting the loop actually moves. More on that below.

The generator: attacks built, not stored

This is the direct inversion of the 1981 model. Instead of replaying stored flight paths, each wave is composed when it spawns. There are four families — arc (a sweeping fan), aimed (volleys at where you sit), spiral (punishes standing still), and pincer (squeezes the column you avoid). Each is a tiny function of the current difficulty and the player model. Here's the one that reads your habits most directly:

shmup.html — the "pincer" family
// pincer — squeeze in from the column the player avoids,
// forcing them out of their safe spot
const coldCol = playerModel.coldestCol();
const px = (coldCol + 0.5) / PM_COLS * W;
for (let i = 0; i < density; i++) {
  const side = i % 2 === 0 ? -1 : 1;
  shots.push({
    x: clamp(px + side * 75, 20, W - 20),
    y: -10 - (i >> 1) * 44,
    vx: -side * 0.8, vy: speed, fam: arm
  });
}

Both density and speed are derived from the governor's difficulty, so the same pincer is gentle early and brutal late. Because the shape is generated rather than stored, no two pincers are identical — and because it's aimed by the live player model, it's about you, not a designer's guess made years ago.

The bandit: learning which weapon works on you

The generator knows how to build four kinds of attack. The bandit decides which to reach for — and this is where genuine learning happens. It's a textbook epsilon-greedy multi-armed bandit: four arms, one per family. Most of the time it plays the arm with the best track record (exploit); occasionally it tries a random one to keep learning (explore), with that exploration rate decaying as it grows confident.

shmup.html — the pattern bandit
const bandit = {
  counts: [0,0,0,0], values: [0,0,0,0],
  epsilon: 0.35, epsilonMin: 0.06, decay: 0.99,
  select() {
    if (Math.random() < this.epsilon) return (Math.random() * 4) | 0;  // explore
    let best = 0;                                          // exploit
    for (let i = 1; i < 4; i++) if (this.values[i] > this.values[best]) best = i;
    return best;
  },
  update(arm, reward) {
    this.counts[arm]++;
    // incremental average: Q ← Q + (1/N)(reward − Q)
    this.values[arm] += (reward - this.values[arm]) / this.counts[arm];
    this.epsilon = Math.max(this.epsilonMin, this.epsilon * this.decay);
  }
};

The clever bit is the reward. We don't reward the bandit for killing the player — that would just make the game cruel. We reward it for near-misses: shots that pass close enough to force a dodge without landing. A pattern that makes you scramble scores well; one you ignore scores poorly. So the bandit converges on the family that keeps you on edge, which is exactly what "never boring" means in design terms. The coloured bars in the live panel are these four values, drawn in real time.

One decision, every wave

The four parts meet in a single function that runs each time a wave spawns. Read top to bottom, it's the whole director in six lines: the governor reads your performance, the bandit picks a family, the generator builds it aimed by your model, and when the wave ends the bandit is told how much pressure it actually applied.

shmup.html — the director's decision point
function launchWave() {
  settleWave();                          // 4. score the previous wave, teach the bandit
  const diff = governor.tick(perf);        // 2. read performance → difficulty
  currentArm = bandit.select();          // 4. choose an attack family
  const shots = buildPattern(currentArm, diff); // 3. build it (1. aimed by player model)
  for (const s of shots) enemyShots.push(s);
  waveTimer = clamp(150 - diff * 30, 55, 150); // harder = faster waves
}

That's the entire intelligence of the game, in one place. Everything else is rendering and input.

You can't see a bandit converge by squinting

A control loop and a learning algorithm have a nasty property: when they're subtly broken, the game still looks fine. Difficulty that secretly froze, or a bandit that never actually updated, would just feel like "the patterns seem a bit samey" — easy to miss, impossible to debug by eye. So the logic is tested the way you'd test any other code.

The bundle includes two test files. ai-core.test.js pulls the bandit, governor and player model out on their own and asserts they behave: that the bandit converges on the genuinely strongest arm in a rigged scenario, that the governor ramps up for a dominating player and backs off for a struggling one while staying bounded, that the player model both finds where you camp and follows you when you move. harness.js goes further — it stubs out the browser entirely and runs the real game headlessly for twelve thousand frames, simulating input, to catch runtime errors and confirm waves actually cycle and the director actually learns during play.

node ai-core.test.js
  ok  bandit identifies the strongest pattern family (spiral)
  ok  bandit explored every arm at least once
  ok  governor ramps UP for a dominating player
  ok  governor backs OFF for a struggling player
  ok  closed loop converges to a stable difficulty
  ok  player model adapts when habits change (top-left)
  ... 11 passed, 0 failed

This is the part that took the project from "plausible" to "actually correct." The frozen-difficulty bug above was found by the harness, not by playing — which is the whole argument for writing the tests at all.

Where to take it

The code is deliberately small and hackable. A few directions if you want to extend it: add a fifth pattern family and watch the bandit fold it into the rotation with no other changes; give the player model a finer grid for more precise aiming; replace the epsilon-greedy bandit with UCB1 to compare exploration strategies; or feed a second performance signal (accuracy, say) into the governor for a richer flow estimate. The architecture takes all of these without restructuring — that's the dividend of keeping the four layers separate.

And the obvious next step, on a machine that has it: wire the reward and player model into a tiny tabular Q-learner so the director reasons about sequences of waves rather than one at a time. The bandit is the gateway drug to reinforcement learning, and this is a clean place to take that step.