There are two cheap ways to make a game's AI feel "hard," and both feel bad. You can give it more resources than the player (the AI that starts with double your army). Or you can rubber-band it — silently buff it when it's losing. Players smell both instantly, and neither is actually intelligent; it's just a thumb on the scale.
We wanted the AI in WarGaze — a browser-based 3D tactical battle sim — to feel like a general who studied your doctrine and built the hard counter. That means reading the player's actual composition and responding to it, on a budget the player can see is fair. Here's how the AI commander works.
The core loop: analyze, then counter-pick
When you lock in your army, the AI doesn't roll a random horde. It analyzes your composition first — bucketing your units into melee, ranged, and machines:
function analyze(composition) {
let melee = 0, ranged = 0, machines = 0, total = 0;
const MELEE = new Set(['knight', 'cavalry', 'brute', 'assassin', 'samurai']);
const MACHINE = new Set(['catapult', 'ballista', 'mech', 'drone']);
for (const [unit, n] of Object.entries(composition)) {
total += n;
if (MELEE.has(unit)) melee += n;
if (MACHINE.has(unit)) machines += n;
// ranged = everything that isn't melee or a support class
}
return { melee, ranged, machines, total };
}
Then it picks a counter-doctrine based on what dominates your army:
if (a.ranged > a.total * 0.55) {
// You went ranged-heavy → rush it with fast backline hunters, spread out
menu = { assassin: 3, ninja: 3, cavalry: 3, drone: 2, knight: 2 };
formation = 'skirmish'; // spread so your AoE can't clump us
} else if (a.melee > a.total * 0.55) {
// You went melee-heavy → AoE mages + catapults behind a knight screen
menu = { mage: 3, catapult: 2, archer: 3, knight: 3, brute: 1 };
formation = 'line';
} else if (a.machines > a.total * 0.25) {
// You leaned on machines → single-target deleters (snipers, ballistae)
menu = { sniper: 3, ballista: 2, assassin: 2, knight: 3, healer: 1 };
formation = 'wedge';
}
This is the whole trick to AI that feels smart: it's not predicting the future or running a neural net. It's a rock-paper-scissors read on the composition you already committed to, expressed as a shift in what it builds and how it deploys. Ranged armies fear a fast rush; melee balls fear AoE; machine-heavy armies fear precision single-target. The AI names the threat and builds the answer.
Fairness is a visible budget, not a hidden buff
The counter only matters if the fight is otherwise fair. So the AI spends against a budget derived from your own army's value, with a small tier multiplier the player can see:
const playerValue = Math.max(200, armyValue(playerComposition));
let budget = playerValue * spec.budgetMul; // 0.8 at RECRUIT → 1.25 at OVERLORD
Each unit has a point value (a hand-tuned hp × dps heuristic), and the AI spends its budget by weighted-random draws from its counter-menu until the money runs out:
while (budget > 0) {
const unit = weightedPick(menu, rng); // weights bias the spend, don't hard-limit
if (UNIT_VALUE[unit] <= budget) {
composition[unit] = (composition[unit] || 0) + 1;
budget -= UNIT_VALUE[unit];
}
}
The difficulty tiers (RECRUIT → VETERAN → ELITE → WARLORD → OVERLORD) scale three honest levers: budget (0.8× to 1.25×), upgrade level (stat multipliers that mirror the player's own upgrade tracks), and whether counter-picking is unlocked at all (only ELITE and up read your comp). A low-tier enemy fields a slightly smaller, dumber army; a high-tier one fields a slightly bigger army that's built to beat yours specifically. No tier ever gets a secret invisible bonus. The player can look at the intel readout — "Formation: skirmish · Doctrine: COUNTER-RUSH" — and understand exactly what they're up against and why.
The weighted-menu trick: variety without chaos
Notice the menus are weights, not fixed recipes. { assassin: 3, ninja: 3, cavalry: 3, drone: 2, knight: 2 } means "mostly fast hunters, some knights" — but the exact army differs every match because the spend is a weighted random draw. This is the sweet spot between two failure modes: a fixed counter-recipe is predictable and gets solved in three games; pure randomness is chaotic and often builds nonsense. Weighted draws give you armies that are recognizably a counter-rush but never identical, so the player re-reads the board every time.
Deployment matters as much as the roster
The AI doesn't just pick what to build — it picks how to deploy, and the formation is part of the counter. Against a mage-heavy player whose fireballs splash, the AI chooses skirmish (scatter across a 34-unit-wide band) so a single AoE can't hit a cluster. Against a melee ball it chooses line to maximize its ranged frontage. The formation is generated by pure position functions, so "spread out" and "form a wall" are just different (index, count) → {x, z} mappings. A good counter that clumps into one fireball is not a good counter.
What to copy
- Read the player's committed state, don't predict. Bucketing a composition and matching a counter-doctrine feels intelligent and costs almost nothing.
- Make difficulty a visible budget + explicit tiers, never a hidden buff. Players forgive losing to a fair, smart opponent; they resent a cheating one.
- Weight your menus, don't fix them. Weighted-random spend gives recognizable-but-varied behavior — the antidote to both "solved in 3 games" and "random nonsense."
- Counter with deployment, not just roster. Where units stand is half the counter; expose formation as pure position functions so it's cheap to vary.
"Smart" game AI is usually not machine learning — it's a designer's rock-paper-scissors knowledge encoded as a read-and-respond rule, spent against a fair budget. WarGaze's commander is ~150 lines. It feels like it's studying you because, in the only way that matters to the player, it is.
Want an opponent AI, simulation, or game system that feels genuinely intelligent? We build these.
