๐Ÿค– AI Chapter
HU EN
AI Chapter ยท Lesson 1

๐Ÿค– Why is game AI different from machine learning?

ChatGPT and AlphaZero are both "artificial intelligence" โ€” yet they operate on completely different principles. In game programming, rule-based AI is the clear winner: predictable, debuggable, and writable in minutes.

โฑ 30 min
๐ŸŽฏ Rule-based AI ยท Decision Making ยท Game Feel

1The AI spectrum

TypeHow it decidesAdvantageDisadvantageExample
Random Math.random() 1 line of code Unpredictable, doesn't feel intelligent Random move every turn
Rule-based If-else, weighted decision Fast, predictable, debuggable Doesn't learn, designer writes all the rules Turtle AI, Galactic AI, Castle enemies
Tree-based (BT/FSM) State machine, decision tree Complex behaviour, easily extensible More design work RPG NPCs, FPS opponents
Machine learning Neural network, Q-learning Human-level or better Millions of training hours, enormous complexity AlphaGo, OpenAI Five
The game developer's reality

In most commercial games โ€” including AAA titles โ€” rule-based AI is used. Not because developers don't know machine learning, but because rule-based AI is predictable, debuggable, and deliberately beatable. A player doesn't want to play against a perfect AI โ€” they want something fun.

2Core principles of good game AI

โœ… WHAT WE WANT
  • โ–ธ Beatable โ€” but not easy
  • โ–ธ Makes mistakes sometimes โ€” feels more human
  • โ–ธ Reacts to player behaviour
  • โ–ธ Different across difficulty levels
  • โ–ธ Fast decision (max a few ms/frame)
โŒ WHAT WE DON'T WANT
  • โœ— Perfect decision every frame
  • โœ— Sees all game state (cheating)
  • โœ— Instantly reacts to every change
  • โœ— Follows a predictable pattern
  • โœ— Millisecond decision time
Comparing the AI of our three games

Turtle Race โ€” target-selecting AI: which egg is best? Timed decision, intentional error.
Castle Siege โ€” pathfollowing AI: enemies travel a fixed lane in waves, no individual decisions.
Galactic Conquest โ€” strategic AI: territory evaluation, three action types, noise-distorted decisions.

๐Ÿง  Why is AI not perfect in most games?

Because developers can't write better AI
Because playing against a perfect AI is frustrating and not fun โ€” mistakes make it feel more human and beatable
Because processors aren't fast enough
Because the rules of the game don't allow it
AI Chapter ยท Lesson 2

๐Ÿข Weighted Target Selection โ€” Scoring System

The simple "nearest egg" heuristic isn't enough. A real decision system simultaneously weighs proximity, energy needs, obstacles, and risk โ€” in a single score.

โฑ 60 min
๐ŸŽฏ Weighted scoring ยท heuristics ยท multi-factor evaluation

1Basic AI vs. weighted AI

๐Ÿข Turtle Race

Let's see the difference between the current simple AI and a weighted decision-maker:

Simple AI โ€” proximity onlyJavaScript
// โŒ SIMPLE: only looks for the nearest egg
G.objects.forEach(o => {
  if (o.type !== 'egg' || o.done) return;
  const sy = o.y + G.scrollY;
  if (sy > AI_Y - 60 && sy < AI_Y + 100) {
    const sc = 90 - Math.abs(o.x - a.x); // distance only!
    if (sc > bestSc) { bestSc = sc; best = o.x; }
  }
});

// Problem: if 2 eggs are equidistant,
// but a seagull is behind one of them โ€” the AI doesn't notice
Weighted AI โ€” multiple factors at onceJavaScript
// โœ… WEIGHTED: proximity + energy + risk together
function scoreTarget(obj, ai) {
  const sy  = obj.y + G.scrollY;
  const dx  = Math.abs(obj.x - ai.x);
  const dy  = Math.abs(sy - AI_Y);

  // 1. PROXIMITY SCORE: closer = better (max 100)
  const distScore = Math.max(0, 100 - dx * 0.8 - dy * 0.3);

  // 2. VALUE SCORE: energy item more important when energy is low
  let valueScore = obj.type === 'egg' ? 50 : 0;
  if (obj.type === 'energy') {
    valueScore = ai.energy < 40 ? 120 : 20; // super valuable at low energy
  }

  // 3. RISK PENALTY: nearby seagull reduces the value
  let riskPenalty = 0;
  G.objects.forEach(threat => {
    if (threat.type !== 'gull' || threat.done) return;
    const gsy = threat.y + G.scrollY;
    const threatDist = Math.hypot(obj.x - threat.x, sy - gsy);
    if (threatDist < 60) riskPenalty += (60 - threatDist); // nearby = penalty
  });

  // Final score: all three factors combined
  return distScore + valueScore - riskPenalty;
}

// Usage โ€” head towards the target with the best score
let bestScore = -Infinity, bestX = W/2;
G.objects.forEach(o => {
  if (o.done) return;
  const sy = o.y + G.scrollY;
  if (sy < AI_Y + 120 && sy > AI_Y - 80) {
    const sc = scoreTarget(o, a);
    if (sc > bestScore) { bestScore = sc; bestX = o.x; }
  }
});
The golden rule of weighting

Different factors have different units ("pixels" vs "energy%") โ€” they need to be normalised (brought to a 0โ€“100 scale) and then weighted. Determining the weights is a playtesting question: try it, feel it, adjust it.

2Complete example: adding surface bonus to the decision score

Adding a surface-based bonusJavaScript
// 4. SURFACE BONUS: if the path to the target is on a good surface
function surfaceBonus(targetX, currentDist) {
  // Check what surface the path toward the target runs through
  const surf = getSurface(G.totalDist + currentDist / 8, G.level);
  return surf === 'road' ? 30 : surf === 'mud' ? -20 : 0;
  // On road: bonus (moves faster)   On mud: penalty (slows down)
}

// Full scoreTarget extended:
function scoreTarget(obj, ai) {
  const sy  = obj.y + G.scrollY;
  const dx  = Math.abs(obj.x - ai.x);

  const distScore   = Math.max(0, 100 - dx * 0.8);
  const valueScore  = obj.type === 'egg' ? 50 :
                       ai.energy < 40  ? 120 : 20;
  const riskPenalty = calcRisk(obj.x, sy);
  const surfBonus   = surfaceBonus(obj.x, dx);

  return distScore + valueScore - riskPenalty + surfBonus;
}
โœ๏ธ Task

Build a simple scoring system:

  • Open turtle_race.html, find the updateAI() function
  • Extend the sc score: if AI energy < 30%, energy items get a +80 bonus
  • Add risk: if any seagull is within 50px of the target, deduct 40 points
  • Watch with F12: console.log(bestScore) โ€” what values appear and when?

๐Ÿง  Why do we need to normalise different factors (to a 0โ€“100 scale)?

Because JavaScript can't add numbers greater than 100
Because values with different units (pixel distance, energy%, risk level) can only be meaningfully compared and weighted when brought to the same scale
To make the AI faster
No particular reason, just convention
AI Chapter ยท Lesson 3

๐Ÿข AI State Machine โ€” When the AI "Feels" Something

A good AI doesn't behave the same way in every situation. When its energy is low, it races after pickups. When it's winning, it's more cautious. When it's jumping, it makes different decisions. That's the state machine.

โฑ 75 min
๐ŸŽฏ FSM ยท AI states ยท context-aware behavior

1AI states โ€” FSM design

๐Ÿƒ HUNTING
Searches for eggs, normal scoring.
Condition: energy > 40%
โ†’
โšก LOW ENERGY
Only searches for energy items, ignores eggs.
Condition: energy < 40%
โ†’
๐Ÿ›ก๏ธ DEFENSIVE
Stays in the middle of the lane, avoids edges.
Condition: lives < 2
turtle_race.html โ€” AI state machine implementationJavaScript
// AI states
const AI_STATES = {
  HUNTING:    'hunting',
  LOW_ENERGY: 'low_energy',
  DEFENSIVE:  'defensive',
};

// The AI has its own state variable
const ai = {
  x: W/2, vx: 0,
  energy: 100, lives: 3,
  state:  AI_STATES.HUNTING,  // starting state
  aiTimer: 0, aiTargetX: W/2,
};

// โ”€โ”€โ”€ UPDATE STATE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function updateAIState() {
  if (ai.lives <= 1) {
    ai.state = AI_STATES.DEFENSIVE;  // low lives โ†’ defensive
  } else if (ai.energy < 40) {
    ai.state = AI_STATES.LOW_ENERGY; // low energy โ†’ chase pickups
  } else {
    ai.state = AI_STATES.HUNTING;    // normal โ†’ hunt eggs
  }
}

// โ”€โ”€โ”€ AI DECISION BASED ON STATE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function updateAI() {
  updateAIState(); // updated every frame

  ai.aiTimer--;
  if (ai.aiTimer > 0) return; // not decision time yet
  ai.aiTimer = 25 + Math.floor(Math.random() * 40);

  switch (ai.state) {

    case AI_STATES.LOW_ENERGY:
      // Only searches for energy โ€” ignores eggs
      ai.aiTargetX = findBest(['energy'], { energyWeight: 200, distWeight: 1 });
      break;

    case AI_STATES.DEFENSIVE:
      // Stays in the middle โ€” low risk
      ai.aiTargetX = W / 2 + (Math.random() - .5) * 60;
      break;

    case AI_STATES.HUNTING:
    default:
      // Normal scoring โ€” eggs + energy + risk
      ai.aiTargetX = findBest(['egg','energy'], { energyWeight: 60, distWeight: 1 });
      break;
  }
}

// findBest: search among given types, with weights
function findBest(types, weights) {
  let bestSc = -Infinity, bestX = W/2;
  G.objects.forEach(o => {
    if (!types.includes(o.type) || o.done) return;
    const sy = o.y + G.scrollY;
    if (sy < AI_Y + 120 && sy > AI_Y - 80) {
      const sc = scoreTarget(o, ai, weights);
      if (sc > bestSc) { bestSc = sc; bestX = o.x; }
    }
  });
  return bestX + ai.aiErr * TW * .4;
}
Why switch instead of if-else chain?

When there are many states, switch is more readable โ€” each case is an independent "behaviour module". If a new state needs to be added later (e.g. AGGRESSIVE when winning), you only need to add one case block, without touching the rest of the code.

โœ๏ธ Task

Add a fourth state: AGGRESSIVE

  • The AI should be "aggressive" when it leads by 10+ eggs over the player
  • In aggressive mode: aiTimer should be halved (decides faster)
  • In aggressive mode: aiErr should be 0 (more accurate targeting)
  • Debug: console.log(ai.state) at every decision โ€” when does it switch state?

๐Ÿง  When does the AI enter LOW_ENERGY state according to our implementation?

When the AI has collected fewer eggs than the player
When the AI's energy value drops below 40%
When the AI has lost fewer than one life
When the AI can't find energy for 5 seconds
AI Chapter ยท Lesson 4

๐ŸŽฎ AI Difficulty Levels โ€” One AI, Three Behaviours

You don't need to write a separate AI for every difficulty level. By changing a single set of parameters โ€” reaction time, error margin, sight range โ€” the AI can be scaled from easy to very hard.

โฑ 45 min
๐ŸŽฏ difficulty config ยท parameter tuning ยท playtesting

1Parameter-based difficulty

Every AI-describing parameter can be influenced by the difficulty level. The key is to keep these parameters in a configuration object, not scattered through the code:

๐Ÿ˜Š Easy
Reaction time45โ€“85 frames
Error margin (aiErr)ยฑ0.5
Sight range80 px
Jump readiness50%
Risk sensitivitylow
๐Ÿ˜ Medium
Reaction time25โ€“55 frames
Error margin (aiErr)ยฑ0.25
Sight range120 px
Jump readiness80%
Risk sensitivitymedium
๐Ÿ˜ˆ Hard
Reaction time8โ€“20 frames
Error margin (aiErr)ยฑ0.05
Sight range180 px
Jump readiness98%
Risk sensitivityhigh
AI difficulty config โ€” for Turtle RaceJavaScript
// Config array โ€” each level has its own parameters
const AI_DIFF = [
  { // 0 = EASY
    name:       'Easy',
    timerMin:   45,  timerRnd:  40,  // 45โ€“85 frame decision delay
    errScale:   0.5,               // large error โ€” often misses
    sight:      80,                // only sees items within 80px
    jumpChance: 0.5,              // 50% chance it jumps over rocks
    riskWeight: 0.3,              // doesn't flee seagulls as much
  },
  { // 1 = MEDIUM
    name:       'Medium',
    timerMin:   25,  timerRnd:  30,
    errScale:   0.25,
    sight:      120,
    jumpChance: 0.8,
    riskWeight: 0.7,
  },
  { // 2 = HARD
    name:       'Hard',
    timerMin:   8,   timerRnd:  12,
    errScale:   0.05,
    sight:      180,
    jumpChance: 0.98,
    riskWeight: 1.2,
  },
];

let DIFF = 1; // current difficulty

// Initialising AI based on difficulty
function initAI() {
  const dc = AI_DIFF[DIFF];
  ai.aiErr = (Math.random() - .5) * dc.errScale; // permanent error offset
  ai.aiTimer = dc.timerMin;
}

// In updateAI: decides based on config
function updateAI() {
  const dc = AI_DIFF[DIFF]; // current config
  ai.aiTimer--;
  if (ai.aiTimer > 0) return;
  ai.aiTimer = dc.timerMin + Math.floor(Math.random() * dc.timerRnd);

  // Sight range: on easy, only sees things closer by
  const sightRange = dc.sight;
  G.objects.forEach(o => {
    const sy = o.y + G.scrollY;
    if (sy < AI_Y + sightRange && sy > AI_Y - sightRange/2) {
      // ... scoring ...
    }
  });

  // Jump readiness: on easy, doesn't always jump over rocks
  G.objects.forEach(o => {
    if (o.type !== 'rock') return;
    const sy = o.y + G.scrollY;
    if (sy > AI_Y-60 && sy < AI_Y+20 && Math.abs(o.x-ai.x) < 35) {
      if (Math.random() < dc.jumpChance) { // doesn't always jump!
        ai.jumping = true; ai.jumpT = 0;
      }
    }
  });
}
โœ๏ธ Task

Add a difficulty selector button to your game:

  • Create 3 HTML buttons: "๐Ÿ˜Š Easy", "๐Ÿ˜ Medium", "๐Ÿ˜ˆ Hard"
  • On click, set the DIFF variable (0, 1, or 2)
  • Call initAI() when difficulty changes
  • Test: does the behaviour noticeably differ across the three levels?

๐Ÿง  What does the aiErr value represent in the AI?

How many total mistakes the AI has made
A permanent, random offset by which the AI systematically "drifts" slightly left or right โ€” this makes the movement feel more human
The proportion of AI's incorrect decisions (between 0โ€“1)
The error margin of the decision interval
AI Chapter ยท Lesson 5

๐Ÿฐ Castle Siege โ€” Enemy Pathfollowing

Castle Siege enemies don't "think" โ€” they head toward a fixed target in the simplest possible way. This is called linear pathfollowing. Yet it feels interesting, because stone pits, the moat, and speed differences add variety.

โฑ 60 min
๐ŸŽฏ Linear movement ยท speed scaling ยท obstacle handling

1Castle Siege enemy movement logic

๐Ÿฐ Castle Siege

Enemy movement is surprisingly simple: they always travel left (toward the castle), and automatically align their Y position to the centre (MIDY). No pathmap, no obstacle avoidance โ€” yet it's effective.

castle_siege.html โ€” updEnemies() movement logicJavaScript
function updEnemies() {
  for (let i = G.enemies.length-1; i >= 0; i--) {
    const e = G.enemies[i];
    const et = ET[e.ti]; // enemy type (Goblin/Orc/Troll)

    // โ”€โ”€โ”€ AT THE CASTLE GATE: ATTACK โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    if (e.x - et.r <= CW) {  // reached the castle wall
      e.atkCD -= DT;
      if (e.atkCD <= 0) {
        const dmg = Math.round(et.dmg * e.dmM);
        G.castle.hp = Math.max(0, G.castle.hp - dmg);
        e.atkCD = 80;
      }
    }

    // โ”€โ”€โ”€ ADVANCING: RIGHT TO LEFT โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    else {
      const sp = eSpdOf(e); // speed: type ร— surface modifiers
      e.x -= sp * DT;        // always moves left (toward castle)

      // Y axis: centre alignment โ€” doesn't go straight, pulls toward centre
      const diff = MIDY - e.y;
      e.vy = Math.sign(diff) * Math.min(Math.abs(diff) * .008, .5);
      e.y += e.vy * DT;
      // This creates natural clustering: everyone drifts toward the lane centre
    }
  }
}

// โ”€โ”€โ”€ SPEED CALCULATION (SURFACE + OBSTACLES) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function eSpdOf(e) {
  const et = ET[e.ti];
  let spd = et.spd * e.spM; // base speed ร— wave multiplier

  // In stone pit: slows to 35% (if pit is on the level)
  G.pits.forEach(p => {
    if (dist(e.x, e.y, p.x, p.y) < s(52)) spd *= .35;
  });

  // In moat: slows to 70% (level-dependent)
  if (SV.level >= 15 && e.x >= CW && e.x <= CW + MOAT_W) spd *= .7;

  return spd;
}
The secret of the .008 multiplier โ€” spring/rubber band movement

The diff * 0.008 formula creates a "spring" effect: the farther the enemy is from the centre line, the faster it pulls back โ€” but never abruptly, always smoothly. This is spring dampening โ€” used throughout games for movement, camera follow, and AI movement alike.

2Enemy types โ€” different behaviour with the same logic

castle_siege.html โ€” ET (Enemy Types)JavaScript
// One array, three different "AI" behaviours โ€” all using the same movement code!
const ET = [
  // Fast, weak โ€” comes in masses, hard to hit
  { name: 'Goblin', hp: 38,  spd: s(1.3),  dmg: 10, gold: 10, r: s(13), col: '#2e7a2e' },
  // Medium โ€” balanced
  { name: 'Orc',    hp: 100, spd: s(0.75), dmg: 22, gold: 28, r: s(18), col: '#8B4513' },
  // Slow, massive HP โ€” hard to stop with tower cannons
  { name: 'Troll',  hp: 280, spd: s(0.38), dmg: 45, gold: 65, r: s(26), col: '#4a5a2a' },
];

// Wave multipliers (hpM, spM) scale these per level:
// Level 15 Goblin: hp=38ร—3.5=133, spd=1.3ร—1.8=2.34
// Same code runs, but the enemy is much stronger
TypeStrategyEffective counterLess effective counter
๐ŸŸข GoblinSpeed โ€” rushes through before towers fire muchFast-firing tower, heroPowerful but slow tower
๐ŸŸค OrcBalanced โ€” not extreme in any wayAny standard towerโ€”
๐ŸŸซ TrollHP tank โ€” absorbs projectiles from towersHigh-damage tower, stone pit (slows)Fast, low-damage tower
โœ๏ธ Task

Add a 4th enemy type to Castle Siege:

  • Create a "Shadow Assassin" type: very fast (spd: 2.5), very low HP (hp: 15), flies over stone pits (not slowed by them)
  • In the eSpdOf() function add: if e.ti === 3, skip the pit check
  • Add the new type to a wave in genLevel() (e.g. from level 12 onward)
  • Test: does the Assassin emerge from the moat faster?

๐Ÿง  What does the Math.sign(diff) * Math.min(Math.abs(diff) * .008, .5) formula do?

Generates random movement on the Y axis
Implements spring-like centring: a force proportional to the distance from MIDY pulls the enemy back, but at a maximum speed of 0.5
Calculates straight-line movement toward the centre
Normalises the enemy's Y coordinate
AI Chapter ยท Lesson 6

๐ŸŒŠ Castle Siege โ€” Wave System and Enemy Spawning

Enemies don't all arrive at once โ€” they come in waves, with different compositions per level. The genLevel() function generates all the waves for all 50 levels with a single mathematical formula.

โฑ 60 min
๐ŸŽฏ Wave generation ยท spawn queue ยท difficulty scaling

1genLevel() โ€” 50 levels from one function

๐Ÿฐ Castle Siege
castle_siege.html โ€” genLevel(li)JavaScript
function genLevel(li) {
  // Wave count: 5 for levels 1โ€“14, 7 for 15โ€“21, then up to 20
  const wc = li < 15 ? 5 : li < 21 ? 7 : Math.min(7 + (li-21), 20);

  const waves = [];
  for (let wi = 0; wi < wc; wi++) {

    // HP multiplier: grows 10% per level, 22% per wave
    const hpM = (1 + li * .10) * (1 + wi * .22);
    // Speed multiplier: grows more slowly
    const spM = (1 + li * .05) * (1 + wi * .08);
    // Damage multiplier: square root of HP (doesn't grow as fast)
    const dmM = Math.sqrt(hpM);

    // Enemy count: grows with level and wave
    const base = 3 + Math.floor(li * .6) + Math.floor(wi * 1.5);

    // Groups: new type appears with each level milestone
    const groups = [{ ti: 0, n: base }]; // Goblins always present
    if (li >= 3) groups.push({ ti: 1, n: Math.max(1, Math.floor((li-2) * .4 + wi * .9)) });
    if (li >= 8) groups.push({ ti: 2, n: Math.max(1, Math.floor((li-7) * .22 + wi * .4)) });
    // From level 3: Orcs too // From level 8: Trolls too

    // Spawn interval: gets faster per level (minimum 14 frames)
    const ivl = Math.max(14, 56 - li * .5);

    waves.push({ groups, hpM, spM, dmM, ivl });
  }
  return waves;
}
Procedural difficulty increase โ€” why is it elegant?

Instead of entering data for 50 levels by hand, the maths does it. The 1 + level * 0.10 multiplier means: enemies are 10% stronger every level. At level 50: 1 + 50 * 0.10 = 6.0 โ€” a Goblin's base HP (38) rises to 228. One formula, infinite variety.

2The Spawn Queue โ€” arriving in order

castle_siege.html โ€” wave spawn logicJavaScript
// Starting a new wave
function advWave() {
  G.waveIdx++;
  if (G.waveIdx >= G.waves.length) { lvlDone(); return; }

  G.phase = 'spawning';
  const wd = G.waves[G.waveIdx];

  // 1. Fill the spawn queue with all group members
  G.spawnQ = [];
  wd.groups.forEach(g => {
    for (let i = 0; i < g.n; i++) {
      G.spawnQ.push({ ti: g.ti, hpM: wd.hpM, spM: wd.spM, dmM: wd.dmM });
    }
  });

  // 2. Shuffle โ€” they don't always arrive in the same order
  for (let i = G.spawnQ.length-1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i+1));
    [G.spawnQ[i], G.spawnQ[j]] = [G.spawnQ[j], G.spawnQ[i]]; // swap
  }

  G.spawnT = wd.ivl; // timer until first enemy
}

// Update loop: removes one from the queue every ivl frames
if (G.phase === 'spawning') {
  G.spawnT -= DT;
  if (G.spawnT <= 0) {
    if (G.spawnQ.length > 0) {
      spawnE(G.spawnQ.shift());  // shift = removes the first element
      G.spawnT = G.waves[G.waveIdx].ivl; // restarts the timer
    } else {
      G.phase = 'fighting'; // all spawned โ†’ fighting phase
    }
  }
}
Queue data structure โ€” FIFO

The spawn queue is a FIFO (First In, First Out) list: what we put in first comes out first. In JavaScript, an array implements it with push() + shift(). The shift() removes and returns the first element โ€” O(n) slow for large arrays, but here there are at most ~30 elements at a time.

โœ๏ธ Task

Modify the wave system:

  • Find the genLevel() function โ€” what is the wave count on level 10? (wc value)
  • Add a "Boss" wave at the end of every level: 1 Troll, but with 5ร— the HP
  • Implement: for the last wave (wi === wc-1), the groups consist only of one Troll with hpM ร— 5
  • Test: can you feel the difference in game difficulty?

๐Ÿง  Why do we shuffle the spawn queue (spawnQ) randomly?

Because JavaScript arrays don't maintain order by default
So the order isn't predictable โ€” if it were always Goblinโ†’Orcโ†’Troll, the player would know exactly when the Troll arrives. Randomness makes it unpredictable and more interesting
Because forEach doesn't guarantee ordering
Technical reason: push() doesn't guarantee ordered extraction
AI Chapter ยท Lesson 7

๐Ÿš€ Galactic Conquest โ€” Hex-grid AI Basics

In the Galactic Conquest turn-based strategy game, the AI evaluates six neighbouring hexagons each turn and can perform three types of actions: expand, attack, reinforce. Simple rules produce complex strategy.

โฑ 90 min
๐ŸŽฏ hex coordinates ยท neighbour search ยท aiTurn()

1The hex-grid coordinate system

๐Ÿš€ Galactic Conquest
galactic_conquest.html โ€” hex neighboursJavaScript
// Finding the neighbours of a hexagon
// In offset coordinates (not axial), even and odd rows are shifted
function hexNeighbors(col, row) {
  // Hex centre in pixels
  const { x: cx, y: cy } = hexCenter(col, row);
  const nbrs = [];

  // Go through all hexagons โ€” neighbour if close enough
  G.hexes.forEach(h => {
    if (h.col === col && h.row === row) return; // not its own neighbour
    const { x, y } = hexCenter(h.col, h.row);
    const d = Math.hypot(x - cx, y - cy); // Pythagorean distance
    if (d < HEX_R * 2.1) nbrs.push([h.col, h.row]); // closer than 2.1 ร— radius
  });

  return nbrs; // [[col1,row1], [col2,row2], ...] (usually 6 neighbours)
}

// Getting a neighbour: getHex(col, row)
function getHex(col, row) {
  return G.hexes.find(h => h.col === col && h.row === row);
}

// Helper functions for the AI
function playerHexes() { return G.hexes.filter(h => h.owner === 'p'); }
function aiHexes()     { return G.hexes.filter(h => h.owner === 'a'); }
function totalStr(owner) {
  return G.hexes
    .filter(h => h.owner === owner)
    .reduce((s, h) => s + h.str, 0); // sum of all strength values
}
Why search for neighbours using pixel distance?

For hexagonal grids, axial coordinates (q, r, s) are mathematically more elegant, but pixel distance also works correctly: if two hex centres are closer than 2.1 ร— radius, they're neighbours. The 2.1 multiplier provides a small tolerance for floating-point rounding errors.

2The AI's turn โ€” building the candidate list

galactic_conquest.html โ€” aiTurn() candidate listJavaScript
function aiTurn() {
  if (G.phase !== 'ai') return;

  // Energy income: based on difficulty
  const dc = DIFF_CFG[DIFF];
  const income = energyIncome('a');
  G.aEnergy = Math.min(G.aEnergy + Math.ceil(income * dc.aiEnergy), 99);

  // 3 actions/turn on easy, 4 on hard
  let actions = 3 + (DIFF === 2 ? 1 : 0);

  for (let act = 0; act < actions; act++) {
    const myHexes = aiHexes();
    if (myHexes.length === 0) break;

    // โ”€โ”€โ”€ BUILD CANDIDATE LIST โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    let moves = [];

    myHexes.forEach(h => {
      hexNeighbors(h.col, h.row).forEach(([c, r]) => {
        const t = getHex(c, r);
        if (!t) return;

        // Player's hex โ†’ ATTACK candidate
        if (t.owner === 'p')
          moves.push({ type: 'attack', from: h, to: t,
            score: t.str + STYPES[t.type].energy }); // weak + valuable = better target

        // Empty hex โ†’ EXPAND candidate (if energy available)
        if (t.owner === null && G.aEnergy >= 3)
          moves.push({ type: 'expand', from: h, to: t,
            score: STYPES[t.type].energy + .5 }); // more valuable sector = better
      });

      // Weak owned hex โ†’ REINFORCE candidate
      if (h.str < 3 && G.aEnergy >= 5)
        moves.push({ type: 'reinforce', from: h, to: h, score: 2 });
    });

    if (moves.length === 0) break; // no possible moves
    // ... sorting and execution in the next lesson ...
  }
}
The three action types and their scores

attack score = t.str + t.energy โ€” weak (low str) and valuable (high energy) hexes are the best targets.
expand score = t.energy + 0.5 โ€” sectors with higher energy production are more useful.
reinforce score = 2 โ€” fixed value, less important than offensive moves.

โœ๏ธ Task

Analyse the Galactic Conquest AI logic:

  • Open galactic_conquest.html, find the aiTurn() function
  • Add a console.log: how many candidate moves were there in a given turn?
  • Change the reinforce score from 2 to 10 โ€” when does the AI reinforce?
  • Observe 5 AI turns: does it always choose the same action type?

๐Ÿง  Why exactly t.str + STYPES[t.type].energy as the attack score?

Random โ€” any formula would have worked
Because the AI simultaneously prefers the most valuable and easiest-to-capture hexes: low str is an easy target, high energy is a valuable prize โ€” adding them together gives a good heuristic
Because it's the only mathematically correct evaluation
Technical limitation: other values don't fit in a float
AI Chapter ยท Lesson 8

๐Ÿš€ Galactic Conquest โ€” Selecting and Executing the Best Move

The candidate list is ready. Now it needs to be sorted by score, the best one selected, and the battle executed โ€” with a random dice roll. The outcome of battle is never certain.

โฑ 60 min
๐ŸŽฏ sort + noise ยท combat resolution ยท action execution

1Sorting and selecting the best move

๐Ÿš€ Galactic Conquest
galactic_conquest.html โ€” sorting and executionJavaScript
// โ”€โ”€โ”€ SORTING: by score + noise โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// Noise (randomness) makes it unpredictable โ€” large on easy, small on hard
moves.sort((a, b) => {
  const noise = DIFF === 0 ? .8 : DIFF === 1 ? .3 : .1;
  return (b.score + Math.random() * noise) - (a.score + Math.random() * noise);
});

// Best move: the first element after sorting
const mv = moves[0];

// โ”€โ”€โ”€ EXECUTION: depends on action type โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
if (mv.type === 'attack') {
  // Combat: AI strength ร— difficulty bonus + random vs. defender strength + random
  const atk = mv.from.str * dc.aiBonus + Math.ceil(Math.random() * 3);
  const def = mv.to.str + Math.ceil(Math.random() * 2);

  if (atk > def) {
    // VICTORY: takes control
    mv.to.owner = 'a';
    mv.to.str = Math.max(1, mv.from.str - 1); // combat weakens the victor too
    mv.to.flash = 18; // visual feedback
  } else {
    // DEFEAT: the attacking hex is weakened
    mv.from.str = Math.max(1, mv.from.str - 1);
  }

} else if (mv.type === 'expand') {
  // EXPAND: claims empty hex for 3 energy
  G.aEnergy -= 3;
  mv.to.owner = 'a'; mv.to.str = 1;

} else if (mv.type === 'reinforce') {
  // REINFORCE: +2 strength for 5 energy
  G.aEnergy -= 5; mv.from.str += 2;
}
Why doesn't it always execute the best move?

Adding Math.random() * noise to the score means the AI sometimes also picks the 2nd or 3rd best move. On easy (noise=0.8) it picks almost anything, on hard (noise=0.1) it almost always picks the best. This single line is what makes the three difficulty levels truly different.

2The mathematics of combat โ€” why is there always risk?

Combat probability โ€” easy to calculateJavaScript
// AI attacks: str=4, aiBonus=1.4 (hard), random 1โ€“3
// Atk minimum: 4ร—1.4 + 1 = 6.6  maximum: 4ร—1.4 + 3 = 8.6

// Defender str=3, random 1โ€“2
// Def minimum: 3+1 = 4  maximum: 3+2 = 5

// Win chance: almost certain (atk min 6.6 > def max 5)
// But! If AI str=1 and defender str=3:
// Atk maximum: 1ร—1.4 + 3 = 4.4  Def minimum: 3+1 = 4
// โ†’ Win chance: ~10% โ€” AI rarely attacks such a hex

// That's why the score = t.str + t.energy heuristic is useful:
// low str โ†’ easier to capture โ†’ higher score โ†’ AI prefers it
const atk = mv.from.str * dc.aiBonus + Math.ceil(Math.random() * 3);
const def = mv.to.str                + Math.ceil(Math.random() * 2);
const win = atk > def; // simple comparison โ€” but never certain due to randomness
โœ๏ธ Task

Modify the combat mechanic:

  • Add a "flanking bonus": if the AI has 2 neighbours adjacent to the target, atk gets +1 bonus
  • Implement: const flanks = hexNeighbors(mv.to.col, mv.to.row).filter(([c,r]) => getHex(c,r)?.owner === 'a').length
  • Add: const atk = mv.from.str * dc.aiBonus + Math.ceil(Math.random()*3) + (flanks >= 2 ? 1 : 0)
  • Watch 10 turns โ€” did the AI become noticeably more aggressive?

๐Ÿง  What does adding Math.random() * noise to the sort condition achieve?

It speeds up the sorting algorithm
It makes move selection unpredictable: the random noise added to scores means the AI sometimes doesn't pick the best move โ€” on easy the noise is large (nearly random), on hard it's small (nearly optimal)
It normalises scores between 0 and 1
It prevents ties during sorting
AI Chapter ยท Lesson 9

๐ŸŽฏ Galactic Conquest โ€” Strategic Depth and Fine-tuning Difficulty

The DIFF_CFG array and the noise parameter determine the AI's entire behaviour. But what does "strategic depth" actually mean โ€” and how can the AI be extended with further tactics?

โฑ 60 min
๐ŸŽฏ DIFF_CFG ยท strategic extension ยท defensive AI

1DIFF_CFG in detail

๐Ÿš€ Galactic Conquest
galactic_conquest.html โ€” DIFF_CFGJavaScript
const DIFF_CFG = [
  { name: 'EASY',
    aiBonus:  .8,   // AI combat strength is WEAKER (0.8ร— multiplier)
    aiEnergy: 1.0, // AI energy income: same as the player
  },
  { name: 'MEDIUM',
    aiBonus:  1.1, // AI is slightly STRONGER (+10%)
    aiEnergy: 1.2, // AI gets 20% more energy per turn
  },
  { name: 'HARD',
    aiBonus:  1.4, // AI is significantly STRONGER (+40%)
    aiEnergy: 1.5, // AI gets 50% more energy โ€” expands faster
  },
];

// On hard, AI can win with medium-strength hexes:
// atk = 3 ร— 1.4 = 4.2, def max = 4 โ†’ nearly certain victory
// On easy str=3 AI: atk max = 3 ร— 0.8 + 3 = 5.4, def min = 1+1=2 โ†’ doesn't always win
Resource advantage vs. skill advantage

The aiEnergy multiplier is the "cheating" method: the AI gets more resources. The aiBonus is the "skill" method: the AI fights more effectively. Good difficulty design combines both in doses โ€” on hard the AI is not only stronger, but also "smarter" (less noise, more resources).

2Extending the AI โ€” defensive strategy

Defensive AI extension โ€” protecting the HQJavaScript
// The current AI doesn't prioritise defending its own HQ
// Extension: if HQ is in danger, reinforcing gets priority

function aiTurnExtended() {
  const dc = DIFF_CFG[DIFF];
  const myHexes = aiHexes();

  // โ”€โ”€โ”€ EMERGENCY: is the HQ in danger? โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  const hq = myHexes.find(h => h.col === COLS-1 && h.row === ROWS-1);
  if (hq) {
    const hqNeighbors = hexNeighbors(hq.col, hq.row);
    const enemyNearHQ = hqNeighbors.filter(([c,r]) => getHex(c,r)?.owner === 'p').length;
    if (enemyNearHQ > 0 && hq.str < 4 && G.aEnergy >= 5) {
      // HQ in danger: immediately reinforces, before any other move
      G.aEnergy -= 5; hq.str += 2;
      addLog('๐Ÿค– AI defends the base!', 'a');
    }
  }

  // โ”€โ”€โ”€ EXPANSION PRIORITY: valuable sectors โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  const bestExpand = myHexes
    .flatMap(h => hexNeighbors(h.col, h.row)
      .map(([c,r]) => ({ hex: getHex(c,r), from: h })))
    .filter(m => m.hex?.owner === null && G.aEnergy >= 3)
    .sort((a,b) => STYPES[b.hex.type].energy - STYPES[a.hex.type].energy)[0];
    // Sorted: most valuable empty hex goes first

  if (bestExpand) {
    G.aEnergy -= 3;
    bestExpand.hex.owner = 'a'; bestExpand.hex.str = 1;
  }
}
โœ๏ธ Task

Add an "aggressive strategy" to the AI:

  • If the AI controls >60% of the map, prefer attacking over expanding
  • Implement: const aiControl = aiHexes().length / G.hexes.length
  • If aiControl > 0.6, multiply the attack score by 2
  • Test on hard difficulty: does the AI become more aggressive when winning?

๐Ÿง  Why does the AI receive more energy income on hard with the aiEnergy: 1.5 multiplier?

Because the AI chooses more expansive sector types
This is "resource cheating" โ€” the AI doesn't play completely fair, it gets a bonus the player doesn't have, compensating for the fact that rule-based AI can't actually "think better"
Because on hard the AI controls more sectors at the start
Just cosmetic difference, the real difficulty is the noise parameter
AI Chapter ยท Lesson 10

๐Ÿ“ AI Design Principles โ€” What Have We Learned?

Three different games, three different AI approaches. How do we generalise these? What do we take with us to the next game?

โฑ 45 min
๐ŸŽฏ AI design patterns ยท playtesting ยท generalisation

1Comparing the three AIs

GameAI typeDecision basisWhat makes it interesting?
๐Ÿข Turtle Race Target-follower + state machine Weighted scoring, timed decision aiErr (human-like error), states, difficulty scaling
๐Ÿฐ Castle Siege Pathfollower (no individual decisions) Fixed direction + spring Y-alignment Type variety, wave system, level scaling
๐Ÿš€ Galactic Conquest Strategic evaluator Candidate list + noise-distorted sort 3 action types, noise level, resource advantage

2Generalisable AI patterns

General AI template โ€” for any gameJavaScript
// โ•โ•โ• GENERAL AI STRUCTURE โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

// 1. STATE MACHINE: determine mode
function updateState(ai, world) {
  if (ai.hp < 20)       ai.state = 'fleeing';
  else if (ai.hp > 80)  ai.state = 'aggressive';
  else                   ai.state = 'balanced';
}

// 2. CANDIDATE LIST: gather possible actions
function buildCandidates(ai, world) {
  return world.targets
    .filter(t => isVisible(ai, t))  // within sight range
    .map(t => ({
      target: t,
      score: scoreTarget(ai, t)     // multi-factor score
    }));
}

// 3. DECISION: best candidate + noise
function decide(candidates, difficulty) {
  const noise = [.8, .35, .05][difficulty]; // easy โ†’ hard
  candidates.sort((a, b) =>
    (b.score + Math.random()*noise) - (a.score + Math.random()*noise)
  );
  return candidates[0]?.target ?? null;
}

// 4. EXECUTION: timed, not every frame
function updateAI(ai, world) {
  updateState(ai, world);           // state always updates
  ai.timer--;
  if (ai.timer > 0) return;         // decision delay
  ai.timer = 20 + ~~(Math.random()*30);

  const candidates = buildCandidates(ai, world);
  ai.target = decide(candidates, DIFF); // โ†’ target determined
  // The actual movement toward the target runs every frame
}

3The 5 golden rules

๐ŸŽฏ
1. Always make it beatable

The AI should not win perfectly. aiErr, noise, and jumpChance all serve this โ€” intentional imperfection.

โฑ
2. Don't decide every frame

The aiTimer delay simulates human reaction time. Instant decisions feel robotic โ€” a random interval feels more natural.

๐Ÿ“Š
3. Keep parameters in a config object

Following the DIFF_CFG and AI_DIFF pattern: keep all AI parameters in one place, no scattered magic numbers in the code.

๐Ÿงช
4. Playtesting is the real debugger

The best AI parameters can't be calculated โ€” they need to be tried. One session of play reveals whether the AI is boring or frustrating.

๐Ÿงฉ
5. Simple rules, complex behaviour

Galactic's 30 lines produce three strategic styles. The Turtle AI's 20 lines simulate human error. The most convincing AI systems are built from simple principles.

โœ๏ธ Summary Task

Design your own AI for a simple Pong clone:

  • The AI paddle follows the ball's Y position โ€” but slowly (spring dampening: diff * 0.05)
  • Add aiErr: the paddle systematically aims slightly higher or lower
  • Implement 3 difficulty levels: easy paddle is slower (diff*0.02), hard is faster (diff*0.12)
  • Add state: if the ball is approaching, "aggressive" mode (faster); if moving away, "idle" (slow return to centre)

๐Ÿง  Which is the most effective method for increasing AI difficulty?

Writing completely different AI logic for each level
Reducing the AI's reaction time to zero
Combining parameters: reaction time, error margin, sight range, resource bonus all at once โ€” kept in the DIFF_CFG object so it's modifiable in one place
Letting the AI choose the optimal move every turn (minimax algorithm)