Lesson 26
Turn-based combat: clarity first, complexity second.
Turn-based combat is about decisions. The player needs to understand whose turn it is, what each action does and what the enemy is likely to do next.
1. Model actors with explicit stats
Start with small actor objects. Health, attack, defense, speed, statuses and cooldowns are enough for many RPG fights. Avoid hiding combat math inside UI handlers. The button should request an action; the combat system should resolve it.
function createActor(name, stats) {
return {
name,
hp: stats.hp,
maxHp: stats.hp,
atk: stats.atk,
def: stats.def || 0,
speed: stats.speed || 10,
statuses: [],
cooldowns: {}
};
}
const player = createActor("Courier Knight", { hp: 42, atk: 8, def: 2 });
const enemy = createActor("Moss Warden", { hp: 36, atk: 7, def: 1 });
Source pattern: Supagames roguelike and dungeon games track player HP, ATK, DEF, turns, blessings and enemy stats as explicit combat data.
2. Actions should be data
If actions are data, the UI can render them and the combat resolver can execute them. This keeps attack descriptions, costs and cooldowns in one place. It also makes balancing easier because numbers are not scattered through click handlers.
const actions = {
strike: { name: "Strike", power: 1.0, cooldown: 0, text: "Basic attack." },
guard: { name: "Guard", block: 0.5, cooldown: 1, text: "Reduce the next hit." },
spark: { name: "Spark", power: 1.6, cooldown: 2, text: "Heavy magic damage." }
};
function canUse(actor, actionId) {
return (actor.cooldowns[actionId] || 0) <= 0;
}
Descriptions are not fluff. They help the player decide. "Heavy magic damage" is better than a mystery button labeled Spark.
3. Resolve one turn at a time
A turn resolver applies status ticks, executes the chosen action, checks defeat, then lets enemies act. Keep the order consistent. If poison ticks before action on one turn and after action on another, players will feel cheated.
function resolvePlayerTurn(actionId) {
tickStatuses(player);
tickCooldowns(player);
if (player.hp <= 0) return endBattle("defeat");
performAction(player, enemy, actionId);
player.cooldowns[actionId] = actions[actionId].cooldown;
if (enemy.hp <= 0) return endBattle("victory");
resolveEnemyTurn();
updateCombatUI();
}
A combat log is useful here. It explains why HP changed and gives you a debugging trail when a player reports strange damage.
4. Enemy intent makes fights fair
Turn-based enemies should not feel random every turn. Showing intent lets the player plan. The enemy might prepare a heavy attack, guard, heal or summon. The player chooses around that information.
function chooseEnemyIntent(enemy, player) {
if (enemy.hp < enemy.maxHp * 0.35 && !enemy.usedHeal) return "heal";
if (player.statuses.some(s => s.id === "guard")) return "pierce";
return Math.random() < 0.25 ? "heavy" : "attack";
}
function resolveEnemyTurn() {
const intent = enemy.intent || chooseEnemyIntent(enemy, player);
performEnemyIntent(enemy, player, intent);
enemy.intent = chooseEnemyIntent(enemy, player);
}
Intent UI can be simple text: "Warden prepares Heavy Slam." This single line turns a random hit into a readable threat.
5. Status effects need duration and timing
Status effects are where turn systems become interesting. Poison, shield, stun, regen, haste and weakness can all use the same structure: id, value and turns. Decide whether the effect ticks at start or end of turn and document it in code.
function addStatus(actor, id, value, turns) {
actor.statuses = actor.statuses.filter(s => s.id !== id);
actor.statuses.push({ id, value, turns });
}
function tickStatuses(actor) {
for (let i = actor.statuses.length - 1; i >= 0; i--) {
const s = actor.statuses[i];
if (s.id === "poison") actor.hp -= s.value;
if (s.id === "regen") actor.hp = Math.min(actor.maxHp, actor.hp + s.value);
s.turns--;
if (s.turns <= 0) actor.statuses.splice(i, 1);
}
}
Statuses should be visible in the UI. Invisible poison is not strategy; it is confusion.
6. Rewards close the loop
After victory, reward should feed the next decision: coins, a blessing, a card, an ingredient, a key or a story flag. If every fight only gives score, combat can feel disconnected from progression.
function endBattle(result) {
state.inBattle = false;
if (result === "victory") {
save.coins += enemy.reward || 10;
save.xp += 8;
showRewardPanel(["+10 coins", "+8 XP"]);
} else {
showDefeatPanel("Retreat, recover and try another plan.");
}
persistSave();
}
Turn-based combat succeeds when every action has a visible result and every fight fits the larger game. Keep the rules readable before adding more moves.
7. Balance one fight before building ten
Design the first enemy like a tutorial. It should survive long enough for the player to try two or three actions, but it should not punish experimentation. Give the enemy a repeated pattern such as attack, attack, heavy wind-up. Then add one counterplay option: guard before the heavy hit, stun during the wind-up or heal after poison. This makes the fight teach the system instead of only testing arithmetic.
When a fight feels unfair, log the turn history. Store actor HP, selected action, enemy intent and damage after each turn. A short log reveals whether the enemy spikes too hard, cooldowns are too long or the player lacks information. Balance is easier when every lost battle can be replayed as data.