๐ค 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.
1The AI spectrum
| Type | How it decides | Advantage | Disadvantage | Example |
|---|---|---|---|---|
| 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 |
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
- โธ 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)
- โ Perfect decision every frame
- โ Sees all game state (cheating)
- โ Instantly reacts to every change
- โ Follows a predictable pattern
- โ Millisecond decision time
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?
๐ข 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.
1Basic AI vs. weighted AI
Let's see the difference between the current simple AI and a weighted decision-maker:
// โ 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: 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; } } });
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
// 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; }
Build a simple scoring system:
- Open
turtle_race.html, find theupdateAI()function - Extend the
scscore: 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)?
๐ข 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.
1AI states โ FSM design
Condition: energy > 40%
Condition: energy < 40%
Condition: lives < 2
// 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; }
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.
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?
๐ฎ 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.
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:
// 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; } } }); }
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?
๐ฐ 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.
1Castle Siege enemy movement logic
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.
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 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
// 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
| Type | Strategy | Effective counter | Less effective counter |
|---|---|---|---|
| ๐ข Goblin | Speed โ rushes through before towers fire much | Fast-firing tower, hero | Powerful but slow tower |
| ๐ค Orc | Balanced โ not extreme in any way | Any standard tower | โ |
| ๐ซ Troll | HP tank โ absorbs projectiles from towers | High-damage tower, stone pit (slows) | Fast, low-damage tower |
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: ife.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?
๐ 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.
1genLevel() โ 50 levels from one function
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; }
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
// 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 } } }
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.
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?
๐ 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.
1The hex-grid coordinate system
// 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 }
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
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 ... } }
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.
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?
๐ 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.
1Sorting and selecting the best move
// โโโ 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; }
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?
// 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
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?
๐ฏ 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?
1DIFF_CFG in detail
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
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
// 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; } }
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?
๐ 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?
1Comparing the three AIs
| Game | AI type | Decision basis | What 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 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
The AI should not win perfectly. aiErr, noise, and jumpChance all serve this โ intentional imperfection.
The aiTimer delay simulates human reaction time. Instant decisions feel robotic โ a random interval feels more natural.
Following the DIFF_CFG and AI_DIFF pattern: keep all AI parameters in one place, no scattered magic numbers in the code.
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.
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.
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)