
Tactical RPG
A role-playing game where you move your characters square by square, in turns, across a battle map, making use of terrain and each one's abilities. Between battles you develop them, and losing one can be costly.
Also known as: TRPG, SRPG, strategy RPG, simulation RPG
Defining mechanics
- A tile map where each unit shows its movement range, and terrain changes both the cost of moving and the protection it gives.
- Phase-based turns, where you move each unit and then it acts (attack, use a skill or wait) before the rival plays.
- Classes with different numbers and roles, counterattacks and a forecast of the outcome before you attack.
- Role-playing progression between battles, with experience, levels and equipment; in many games, a unit that falls is lost for good.
Gameplay loop
Read the map and the threats, take each unit to the right tile, attack, watch the enemy respond and adjust; between battles, improve the party.
Our demo
Controls. Click a blue unit to see its range, click a blue tile to move it and click an enemy outlined in red to attack (or any other tile to wait). "End turn" or the E key passes the turn; Escape cancels before moving. On mobile, tap.
Open full screenThree heroes against three bandits and their captain, on a tile board with forest and rocks. Each turn you move every hero within its range and attack a neighbouring enemy; then the rival plays. Enough to feel what defines the genre, which is deciding where each unit stands.
Made with Kaplay, in about 195 lines of JavaScript.
How it's made
Setup
The tile size, where the board starts and the pause between the rival's actions. That pause matters, because in a turn-based game the player needs to see what each enemy did.
kaplay({ width: 640, height: 360, letterbox: true, pixelDensity, background: [24, 26, 38], // Played with the mouse or a finger; the keys are only shortcuts. buttons: { start: { keyboard: ["space", "enter"] }, endTurn: { keyboard: ["e"] }, cancel: { keyboard: ["escape"] }, }, }); const TILE = 40, OX = 8, OY = 36; // tile size and top-left corner of the board const DIRS = [[1, 0], [-1, 0], [0, 1], [0, -1]]; const ENEMY_DELAY = 0.6; // seconds between enemy actions, so you can follow themThe battlefield
The map is text, where each letter is a terrain. Rock blocks the way and forest costs two movement points but protects whoever stands on it. Terrain turns an empty board into a tactical problem.
// The battlefield as text. . grass · # rock (blocks the way) · f forest // (entering costs 2 moves, and a unit standing on it takes 1 less damage). const MAP = [ "....f..#....", ".f.....#..f.", "...#..f.....", "...#....#...", "......ff#...", ".f..#.......", "....#..f..f.", "......#.....", ]; const COLS = MAP[0].length, ROWS = MAP.length; const terrain = (c, r) => MAP[r]?.[c] ?? "#"; // outside the board counts as rock const stepCost = (c, r) => ({ ".": 1, f: 2 })[terrain(c, r)] ?? Infinity; const tileCenter = (c, r) => vec2(OX + c * TILE + TILE / 2, OY + r * TILE + TILE / 2);The units
Each unit is a row of numbers (health, attack, defence and movement) plus its tile. The knight endures, the lancer hits hard and the scout reaches far but is fragile. Designing these differences is designing the game.
// A unit is a handful of numbers: health, attack, defence and how far it moves. const ROSTER = [ { team: "hero", label: { es: "Caballero", en: "Knight" }, col: 1, row: 3, hp: 18, atk: 7, def: 3, moves: 3 }, { team: "hero", label: { es: "Lancera", en: "Lancer" }, col: 1, row: 5, hp: 14, atk: 8, def: 2, moves: 4 }, { team: "hero", label: { es: "Exploradora", en: "Scout" }, col: 0, row: 4, hp: 11, atk: 6, def: 1, moves: 5 }, { team: "foe", label: { es: "Bandido", en: "Bandit" }, col: 8, row: 1, hp: 10, atk: 6, def: 1, moves: 3 }, { team: "foe", label: { es: "Bandido", en: "Bandit" }, col: 10, row: 2, hp: 10, atk: 6, def: 1, moves: 3 }, { team: "foe", label: { es: "Bandido", en: "Bandit" }, col: 9, row: 6, hp: 10, atk: 6, def: 1, moves: 3 }, { team: "foe", label: { es: "Jefe", en: "Captain" }, col: 11, row: 4, hp: 16, atk: 7, def: 2, moves: 2, guard: true }, ];The board
Each tile is drawn according to its terrain and the units are placed. The bar under each one shows its health, and once it has acted this turn it is drawn faded.
// Draw the map, then place each unit from the roster on its tile. for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { add([rect(TILE - 2, TILE - 2, { radius: 3 }), pos(OX + c * TILE + 1, OY + r * TILE + 1), color({ ".": rgb(70, 110, 70), f: rgb(36, 78, 46), "#": rgb(110, 104, 100) }[terrain(c, r)])]); if (terrain(c, r) === "f") add([polygon([vec2(0, -9), vec2(8, 7), vec2(-8, 7)]), pos(tileCenter(c, r)), color(70, 140, 80)]); } for (const d of ROSTER) { const u = add([rect(28, 28, { radius: 6 }), pos(tileCenter(d.col, d.row)), anchor("center"), opacity(1), z(10), color(d.team === "hero" ? rgb(80, 140, 240) : rgb(210, 80, 70)), outline(2, rgb(15, 15, 25)), "unit", d.team, { ...d, label: t(d.label), maxHp: d.hp, done: false }]); u.add([text(u.label[0], { size: 16, font: FONT }), anchor("center")]); u.onDraw(() => { drawRect({ pos: vec2(-14, 16), width: 28, height: 4, color: rgb(40, 20, 20) }); drawRect({ pos: vec2(-14, 16), width: (28 * u.hp) / u.maxHp, height: 4, color: rgb(120, 230, 120) }); }); u.onUpdate(() => (u.opacity = u.done ? 0.5 : 1)); // greyed out once it has acted } const unitAt = (c, r) => get("unit").find((u) => u.col === c && u.row === r); const adjacent = (a, c, r) => Math.abs(a.col - c) + Math.abs(a.row - r) === 1; function place(u, c, r) { [u.col, u.row] = [c, r]; tween(u.pos, tileCenter(c, r), 0.2, (p) => (u.pos = p), easings.easeOutQuad); }Movement range
This is the genre's core algorithm. A search spreads from the unit through neighbouring tiles, adding up the cost of entering each one, and stops when the movement points run out. Enemies and rocks block the way, and allies can be passed through but not stopped on. The result is the blue tiles.
// Flood fill from the unit: a tile is reachable if the moves spent to get // there fit in its budget. Forest costs more, rocks and enemies block, // allies can be walked through but not stood on. function reachable(u) { const spent = new Map([[`${u.col},${u.row}`, 0]]), open = [[u.col, u.row]]; while (open.length) { const [c, r] = open.shift(); for (const [dc, dr] of DIRS) { const [nc, nr] = [c + dc, r + dr]; const cost = spent.get(`${c},${r}`) + stepCost(nc, nr); const other = unitAt(nc, nr); if (cost > u.moves || (other && other.team !== u.team) || spent.get(`${nc},${nr}`) <= cost) continue; spent.set(`${nc},${nr}`, cost); open.push([nc, nr]); } } return [...spent.keys()].map((k) => k.split(",").map(Number)).filter(([c, r]) => !unitAt(c, r) || unitAt(c, r) === u); }Your phase
Each hero takes two steps: move, then attack or wait. Once it has moved it cannot undo it, which is why thinking before moving is the heart of the genre. When everyone has acted, or if you press «End turn», the rival plays.
// Click a hero, click a blue tile to move, then a red enemy to attack // (or anywhere else to wait). When every hero has acted, the enemy plays. const isMarked = (c, r) => marked.some(([mc, mr]) => mc === c && mr === r); function finish(u) { u.done = true; [selected, mode, marked] = [null, null, []]; if (phase === "player" && get("hero").every((h) => h.done)) enemyPhase(); } onMousePress(() => { if (phase !== "player") return; const [c, r] = tileUnder(); if (c < 0 || r < 0 || c >= COLS || r >= ROWS) return; const u = unitAt(c, r); if (mode === "move" && isMarked(c, r)) { place(selected, c, r); const targets = get("foe").filter((f) => adjacent(f, c, r)); if (!targets.length) return finish(selected); // nobody to attack: the turn of this unit ends [mode, marked] = ["act", targets.map((f) => [f.col, f.row])]; } else if (mode === "act") { if (u && isMarked(c, r)) fight(selected, u); finish(selected); } else if (u?.team === "hero" && !u.done) [selected, mode, marked] = [u, "move", reachable(u)]; else [selected, mode, marked] = [null, null, []]; }); // Cancel only before moving: once a unit moves, it has committed. onButtonPress("cancel", () => mode === "move" && ([selected, mode, marked] = [null, null, []])); onButtonPress("endTurn", () => phase === "player" && mode !== "act" && enemyPhase()); add([z(5)]).onDraw(() => { for (const [c, r] of marked) drawRect({ pos: vec2(OX + c * TILE + 2, OY + r * TILE + 2), width: TILE - 4, height: TILE - 4, // blue: move here · red: attack this ...(mode === "move" ? { color: rgb(90, 170, 255), opacity: 0.45 } : { fill: false, outline: { width: 4, color: rgb(255, 70, 60) } }) }); if (selected) drawRect({ pos: selected.pos.sub(17, 17), width: 34, height: 34, fill: false, outline: { width: 2, color: rgb(255, 240, 150) } }); });Combat
Damage is attack minus defence, at least 1, and forest takes off one more. If the defender survives it strikes back, so every attack has a cost. The side panel tells you how much you will deal and receive before you attack.
// Damage is attack minus defence (minimum 1), with -1 on a forest tile. // If the defender survives, it strikes back: attacking always has a cost. const damage = (a, d) => Math.max(1, a.atk - d.def - (terrain(d.col, d.row) === "f" ? 1 : 0)); function strike(a, d) { const dmg = damage(a, d); d.hp -= dmg; add([text(`-${dmg}`, { size: 18, font: FONT }), pos(d.pos.sub(0, 22)), anchor("center"), color(255, 230, 120), z(30), move(vec2(0, -1), 30), opacity(1), lifespan(0.8, { fade: 0.4 })]); if (d.hp <= 0) destroy(d); } function fight(a, d) { strike(a, d); if (d.hp > 0) strike(d, a); // counterattack if (!get("foe").length || !get("hero").length) { phase = "over"; wait(1, () => go("end", get("hero").length > 0, turn)); } }The enemy AI
A simple but readable AI. Each enemy tries every tile within reach and every hero next to each one, and keeps the best attack (finishing someone off first, then the most damage and, on a tie, whoever has the least health). If it cannot reach anyone, it moves closer. The captain does not move until you enter its zone.
// A simple but readable AI. Each enemy looks at every tile it can reach and // every hero next to that tile, and picks the best attack: a kill first, // then the most damage. If nobody is in reach, it walks towards the heroes. function enemyAct(e) { let best = null; for (const [c, r] of reachable(e)) for (const h of get("hero").filter((h) => adjacent(h, c, r))) { const score = (damage(e, h) >= h.hp ? 100 : 0) + damage(e, h) * 10 - h.hp; if (!best || score > best.score) best = { c, r, h, score }; } if (best) { place(e, best.c, best.r); return wait(0.25, () => fight(e, best.h)); } if (e.guard) return; // the captain holds its position const distance = ([c, r]) => Math.min(...get("hero").map((h) => Math.abs(h.col - c) + Math.abs(h.row - r))); const [c, r] = reachable(e).reduce((a, b) => (distance(b) < distance(a) ? b : a)); place(e, c, r); } function enemyPhase() { [phase, selected, mode, marked] = ["enemy", null, null, []]; const foes = get("foe"); foes.forEach((e, i) => wait(ENEMY_DELAY * (i + 1), () => phase === "enemy" && e.exists() && enemyAct(e))); wait(ENEMY_DELAY * (foes.length + 1) + 0.3, () => { if (phase !== "enemy") return; [phase, turn] = ["player", turn + 1]; get("hero").forEach((h) => (h.done = false)); }); }On-screen information
In a tactical game, information is the strategy. The panel shows the numbers of the unit under the cursor and, while aiming, a forecast of the exchange, so you decide with data rather than blindly.
// The side panel shows the unit under the cursor and, while aiming, a // forecast of the exchange: in this genre, information is the strategy. const banner = add([text("", { size: 14, font: FONT }), pos(OX, 10)]); const info = add([text("", { size: 13, font: FONT, width: 128, lineSpacing: 3 }), pos(500, 40)]); const button = add([rect(128, 30, { radius: 4 }), pos(500, 320), color(50, 56, 80), outline(2, rgb(150, 170, 255)), area()]); button.add([text(t({ es: "Terminar turno (E)", en: "End turn (E)" }), { size: 13, font: FONT }), pos(64, 15), anchor("center")]); button.onClick(() => phase === "player" && mode !== "act" && enemyPhase()); onUpdate(() => { banner.text = phase === "enemy" ? t({ es: `Turno ${turn} · Juega el enemigo…`, en: `Turn ${turn} · Enemy phase…` }) : t({ es: `Turno ${turn} · Tu fase: mueve y ataca con cada unidad azul`, en: `Turn ${turn} · Your phase: move and attack with each blue unit` }); const u = unitAt(...tileUnder()) ?? selected; const stats = u ? `${u.label}\n${t({ es: "Vida", en: "HP" })} ${u.hp}/${u.maxHp}\n${t({ es: "Ataque", en: "Attack" })} ${u.atk}\n${t({ es: "Defensa", en: "Defence" })} ${u.def}\n${t({ es: "Movimiento", en: "Move" })} ${u.moves}` : ""; const aim = mode === "act" && u?.team === "foe" && isMarked(u.col, u.row) ? `\n\n${t({ es: "Harás", en: "You deal" })} ${damage(selected, u)}\n${t({ es: "Te devuelve", en: "It hits back" })} ${damage(u, selected) * (damage(selected, u) < u.hp ? 1 : 0)}` : ""; info.text = stats + aim + `\n\n${t({ es: "Bosque: cuesta 2 y\nresta 1 de daño", en: "Forest: costs 2 and\nblocks 1 damage" })}\n${t({ es: "Roca: no se pasa", en: "Rock: impassable" })}`; });Scenes
Three scenes (title, game and end). The end screen tells you whether you won and in how many turns, a simple way to invite you to do better.
function screen(title, subtitle, next) { add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 85)), anchor("center")]); add([text(subtitle, { size: 15, font: FONT, align: "center", width: 580 }), pos(center().add(0, 25)), anchor("center"), color(200, 210, 255)]); onButtonPress("start", next); onClick(next); } scene("title", () => screen( t({ es: "Rol táctico", en: "Tactical RPG" }), t({ es: "Haz clic en una unidad azul: las casillas azules muestran hasta dónde llega. Haz clic en una para moverte y luego en un enemigo rojo adyacente para atacar (o en otro lugar para esperar). El enemigo juega cuando todas tus unidades actuaron.\nDerrota a todos los enemigos sin perder a todos tus héroes.\nHaz clic o pulsa espacio para empezar.", en: "Click a blue unit: the blue tiles show how far it can go. Click one to move, then click an adjacent red enemy to attack (or anywhere else to wait). The enemy plays once all your units have acted.\nDefeat every enemy before you lose all your heroes.\nClick or press space to start.", }), () => go("game"), ), ); scene("end", (won, turn) => screen( won ? t({ es: "¡Victoria!", en: "Victory!" }) : t({ es: "Derrota", en: "Defeat" }), won ? t({ es: `Despejaste el campo en ${turn} turnos. Haz clic para jugar otra vez.`, en: `You cleared the field in ${turn} turns. Click to play again.` }) : t({ es: "Cayeron todos tus héroes. Haz clic para intentarlo otra vez.", en: "All your heroes fell. Click to try again." }), () => go("game"), )); go("title");
What we simplified
- There is no randomness. Damage is attack minus defence, so you can work out every exchange before making it.
- There is no experience, levels, items or classes with skills; units differ only in their numbers.
- Everyone attacks in melee and there is a single battle, with no campaign between fights.
To modify it
- Add an archer who attacks from two tiles away and takes no melee counterattack.
- Make each defeated enemy give experience, and have the hero's numbers improve on levelling up.
- Add a hit chance that depends on the terrain and show it in the forecast. Does the game become more or less tactical?
Full code
// Tactical RPG demo
// Engine: Kaplay 3001 (https://kaplayjs.com). MIT licence.
import kaplay from "../../vendor/kaplay.mjs";
import { t, FONT, pixelDensity } from "../common.js";
// #region setup
kaplay({
width: 640,
height: 360,
letterbox: true, pixelDensity,
background: [24, 26, 38],
// Played with the mouse or a finger; the keys are only shortcuts.
buttons: {
start: { keyboard: ["space", "enter"] },
endTurn: { keyboard: ["e"] },
cancel: { keyboard: ["escape"] },
},
});
const TILE = 40, OX = 8, OY = 36; // tile size and top-left corner of the board
const DIRS = [[1, 0], [-1, 0], [0, 1], [0, -1]];
const ENEMY_DELAY = 0.6; // seconds between enemy actions, so you can follow them
// #endregion
// #region map
// The battlefield as text. . grass · # rock (blocks the way) · f forest
// (entering costs 2 moves, and a unit standing on it takes 1 less damage).
const MAP = [
"....f..#....",
".f.....#..f.",
"...#..f.....",
"...#....#...",
"......ff#...",
".f..#.......",
"....#..f..f.",
"......#.....",
];
const COLS = MAP[0].length, ROWS = MAP.length;
const terrain = (c, r) => MAP[r]?.[c] ?? "#"; // outside the board counts as rock
const stepCost = (c, r) => ({ ".": 1, f: 2 })[terrain(c, r)] ?? Infinity;
const tileCenter = (c, r) => vec2(OX + c * TILE + TILE / 2, OY + r * TILE + TILE / 2);
// #endregion
// #region units
// A unit is a handful of numbers: health, attack, defence and how far it moves.
const ROSTER = [
{ team: "hero", label: { es: "Caballero", en: "Knight" }, col: 1, row: 3, hp: 18, atk: 7, def: 3, moves: 3 },
{ team: "hero", label: { es: "Lancera", en: "Lancer" }, col: 1, row: 5, hp: 14, atk: 8, def: 2, moves: 4 },
{ team: "hero", label: { es: "Exploradora", en: "Scout" }, col: 0, row: 4, hp: 11, atk: 6, def: 1, moves: 5 },
{ team: "foe", label: { es: "Bandido", en: "Bandit" }, col: 8, row: 1, hp: 10, atk: 6, def: 1, moves: 3 },
{ team: "foe", label: { es: "Bandido", en: "Bandit" }, col: 10, row: 2, hp: 10, atk: 6, def: 1, moves: 3 },
{ team: "foe", label: { es: "Bandido", en: "Bandit" }, col: 9, row: 6, hp: 10, atk: 6, def: 1, moves: 3 },
{ team: "foe", label: { es: "Jefe", en: "Captain" }, col: 11, row: 4, hp: 16, atk: 7, def: 2, moves: 2, guard: true },
];
// #endregion
scene("game", () => {
let phase = "player", turn = 1; // phase: player · enemy · over
let selected = null, mode = null; // the hero receiving orders; mode: "move" (where to go) or "act" (whom to attack)
let marked = []; // highlighted tiles, as [column, row]
const tileUnder = () => [Math.floor((mousePos().x - OX) / TILE), Math.floor((mousePos().y - OY) / TILE)];
// #region board
// Draw the map, then place each unit from the roster on its tile.
for (let r = 0; r < ROWS; r++)
for (let c = 0; c < COLS; c++) {
add([rect(TILE - 2, TILE - 2, { radius: 3 }), pos(OX + c * TILE + 1, OY + r * TILE + 1),
color({ ".": rgb(70, 110, 70), f: rgb(36, 78, 46), "#": rgb(110, 104, 100) }[terrain(c, r)])]);
if (terrain(c, r) === "f") add([polygon([vec2(0, -9), vec2(8, 7), vec2(-8, 7)]), pos(tileCenter(c, r)), color(70, 140, 80)]);
}
for (const d of ROSTER) {
const u = add([rect(28, 28, { radius: 6 }), pos(tileCenter(d.col, d.row)), anchor("center"), opacity(1), z(10),
color(d.team === "hero" ? rgb(80, 140, 240) : rgb(210, 80, 70)), outline(2, rgb(15, 15, 25)), "unit", d.team,
{ ...d, label: t(d.label), maxHp: d.hp, done: false }]);
u.add([text(u.label[0], { size: 16, font: FONT }), anchor("center")]);
u.onDraw(() => {
drawRect({ pos: vec2(-14, 16), width: 28, height: 4, color: rgb(40, 20, 20) });
drawRect({ pos: vec2(-14, 16), width: (28 * u.hp) / u.maxHp, height: 4, color: rgb(120, 230, 120) });
});
u.onUpdate(() => (u.opacity = u.done ? 0.5 : 1)); // greyed out once it has acted
}
const unitAt = (c, r) => get("unit").find((u) => u.col === c && u.row === r);
const adjacent = (a, c, r) => Math.abs(a.col - c) + Math.abs(a.row - r) === 1;
function place(u, c, r) {
[u.col, u.row] = [c, r];
tween(u.pos, tileCenter(c, r), 0.2, (p) => (u.pos = p), easings.easeOutQuad);
}
// #endregion
// #region movement-range
// Flood fill from the unit: a tile is reachable if the moves spent to get
// there fit in its budget. Forest costs more, rocks and enemies block,
// allies can be walked through but not stood on.
function reachable(u) {
const spent = new Map([[`${u.col},${u.row}`, 0]]), open = [[u.col, u.row]];
while (open.length) {
const [c, r] = open.shift();
for (const [dc, dr] of DIRS) {
const [nc, nr] = [c + dc, r + dr];
const cost = spent.get(`${c},${r}`) + stepCost(nc, nr);
const other = unitAt(nc, nr);
if (cost > u.moves || (other && other.team !== u.team) || spent.get(`${nc},${nr}`) <= cost) continue;
spent.set(`${nc},${nr}`, cost);
open.push([nc, nr]);
}
}
return [...spent.keys()].map((k) => k.split(",").map(Number)).filter(([c, r]) => !unitAt(c, r) || unitAt(c, r) === u);
}
// #endregion
// #region player-turn
// Click a hero, click a blue tile to move, then a red enemy to attack
// (or anywhere else to wait). When every hero has acted, the enemy plays.
const isMarked = (c, r) => marked.some(([mc, mr]) => mc === c && mr === r);
function finish(u) {
u.done = true;
[selected, mode, marked] = [null, null, []];
if (phase === "player" && get("hero").every((h) => h.done)) enemyPhase();
}
onMousePress(() => {
if (phase !== "player") return;
const [c, r] = tileUnder();
if (c < 0 || r < 0 || c >= COLS || r >= ROWS) return;
const u = unitAt(c, r);
if (mode === "move" && isMarked(c, r)) {
place(selected, c, r);
const targets = get("foe").filter((f) => adjacent(f, c, r));
if (!targets.length) return finish(selected); // nobody to attack: the turn of this unit ends
[mode, marked] = ["act", targets.map((f) => [f.col, f.row])];
} else if (mode === "act") {
if (u && isMarked(c, r)) fight(selected, u);
finish(selected);
} else if (u?.team === "hero" && !u.done) [selected, mode, marked] = [u, "move", reachable(u)];
else [selected, mode, marked] = [null, null, []];
});
// Cancel only before moving: once a unit moves, it has committed.
onButtonPress("cancel", () => mode === "move" && ([selected, mode, marked] = [null, null, []]));
onButtonPress("endTurn", () => phase === "player" && mode !== "act" && enemyPhase());
add([z(5)]).onDraw(() => {
for (const [c, r] of marked)
drawRect({ pos: vec2(OX + c * TILE + 2, OY + r * TILE + 2), width: TILE - 4, height: TILE - 4, // blue: move here · red: attack this
...(mode === "move" ? { color: rgb(90, 170, 255), opacity: 0.45 } : { fill: false, outline: { width: 4, color: rgb(255, 70, 60) } }) });
if (selected) drawRect({ pos: selected.pos.sub(17, 17), width: 34, height: 34, fill: false, outline: { width: 2, color: rgb(255, 240, 150) } });
});
// #endregion
// #region combat
// Damage is attack minus defence (minimum 1), with -1 on a forest tile.
// If the defender survives, it strikes back: attacking always has a cost.
const damage = (a, d) => Math.max(1, a.atk - d.def - (terrain(d.col, d.row) === "f" ? 1 : 0));
function strike(a, d) {
const dmg = damage(a, d);
d.hp -= dmg;
add([text(`-${dmg}`, { size: 18, font: FONT }), pos(d.pos.sub(0, 22)), anchor("center"), color(255, 230, 120), z(30),
move(vec2(0, -1), 30), opacity(1), lifespan(0.8, { fade: 0.4 })]);
if (d.hp <= 0) destroy(d);
}
function fight(a, d) {
strike(a, d);
if (d.hp > 0) strike(d, a); // counterattack
if (!get("foe").length || !get("hero").length) {
phase = "over";
wait(1, () => go("end", get("hero").length > 0, turn));
}
}
// #endregion
// #region enemy-ai
// A simple but readable AI. Each enemy looks at every tile it can reach and
// every hero next to that tile, and picks the best attack: a kill first,
// then the most damage. If nobody is in reach, it walks towards the heroes.
function enemyAct(e) {
let best = null;
for (const [c, r] of reachable(e))
for (const h of get("hero").filter((h) => adjacent(h, c, r))) {
const score = (damage(e, h) >= h.hp ? 100 : 0) + damage(e, h) * 10 - h.hp;
if (!best || score > best.score) best = { c, r, h, score };
}
if (best) {
place(e, best.c, best.r);
return wait(0.25, () => fight(e, best.h));
}
if (e.guard) return; // the captain holds its position
const distance = ([c, r]) => Math.min(...get("hero").map((h) => Math.abs(h.col - c) + Math.abs(h.row - r)));
const [c, r] = reachable(e).reduce((a, b) => (distance(b) < distance(a) ? b : a));
place(e, c, r);
}
function enemyPhase() {
[phase, selected, mode, marked] = ["enemy", null, null, []];
const foes = get("foe");
foes.forEach((e, i) => wait(ENEMY_DELAY * (i + 1), () => phase === "enemy" && e.exists() && enemyAct(e)));
wait(ENEMY_DELAY * (foes.length + 1) + 0.3, () => {
if (phase !== "enemy") return;
[phase, turn] = ["player", turn + 1];
get("hero").forEach((h) => (h.done = false));
});
}
// #endregion
// #region hud
// The side panel shows the unit under the cursor and, while aiming, a
// forecast of the exchange: in this genre, information is the strategy.
const banner = add([text("", { size: 14, font: FONT }), pos(OX, 10)]);
const info = add([text("", { size: 13, font: FONT, width: 128, lineSpacing: 3 }), pos(500, 40)]);
const button = add([rect(128, 30, { radius: 4 }), pos(500, 320), color(50, 56, 80), outline(2, rgb(150, 170, 255)), area()]);
button.add([text(t({ es: "Terminar turno (E)", en: "End turn (E)" }), { size: 13, font: FONT }), pos(64, 15), anchor("center")]);
button.onClick(() => phase === "player" && mode !== "act" && enemyPhase());
onUpdate(() => {
banner.text = phase === "enemy"
? t({ es: `Turno ${turn} · Juega el enemigo…`, en: `Turn ${turn} · Enemy phase…` })
: t({ es: `Turno ${turn} · Tu fase: mueve y ataca con cada unidad azul`, en: `Turn ${turn} · Your phase: move and attack with each blue unit` });
const u = unitAt(...tileUnder()) ?? selected;
const stats = u ? `${u.label}\n${t({ es: "Vida", en: "HP" })} ${u.hp}/${u.maxHp}\n${t({ es: "Ataque", en: "Attack" })} ${u.atk}\n${t({ es: "Defensa", en: "Defence" })} ${u.def}\n${t({ es: "Movimiento", en: "Move" })} ${u.moves}` : "";
const aim = mode === "act" && u?.team === "foe" && isMarked(u.col, u.row)
? `\n\n${t({ es: "Harás", en: "You deal" })} ${damage(selected, u)}\n${t({ es: "Te devuelve", en: "It hits back" })} ${damage(u, selected) * (damage(selected, u) < u.hp ? 1 : 0)}`
: "";
info.text = stats + aim + `\n\n${t({ es: "Bosque: cuesta 2 y\nresta 1 de daño", en: "Forest: costs 2 and\nblocks 1 damage" })}\n${t({ es: "Roca: no se pasa", en: "Rock: impassable" })}`;
});
// #endregion
});
// #region scenes
function screen(title, subtitle, next) {
add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 85)), anchor("center")]);
add([text(subtitle, { size: 15, font: FONT, align: "center", width: 580 }), pos(center().add(0, 25)), anchor("center"), color(200, 210, 255)]);
onButtonPress("start", next);
onClick(next);
}
scene("title", () =>
screen(
t({ es: "Rol táctico", en: "Tactical RPG" }),
t({
es: "Haz clic en una unidad azul: las casillas azules muestran hasta dónde llega. Haz clic en una para moverte y luego en un enemigo rojo adyacente para atacar (o en otro lugar para esperar). El enemigo juega cuando todas tus unidades actuaron.\nDerrota a todos los enemigos sin perder a todos tus héroes.\nHaz clic o pulsa espacio para empezar.",
en: "Click a blue unit: the blue tiles show how far it can go. Click one to move, then click an adjacent red enemy to attack (or anywhere else to wait). The enemy plays once all your units have acted.\nDefeat every enemy before you lose all your heroes.\nClick or press space to start.",
}),
() => go("game"),
),
);
scene("end", (won, turn) => screen(
won ? t({ es: "¡Victoria!", en: "Victory!" }) : t({ es: "Derrota", en: "Defeat" }),
won
? t({ es: `Despejaste el campo en ${turn} turnos. Haz clic para jugar otra vez.`, en: `You cleared the field in ${turn} turns. Click to play again.` })
: t({ es: "Cayeron todos tus héroes. Haz clic para intentarlo otra vez.", en: "All your heroes fell. Click to try again." }),
() => go("game"),
));
go("title");
// #endregionDesign challenges
- Making information readable, that is, ranges, threat zones and damage forecasts, so the player decides with data.
- An enemy AI that is a challenge without cheating and whose decisions make sense.
- Balancing classes and maps so that no single strategy always wins.
- Measuring out randomness and permanent death so that defeat feels fair rather than arbitrary.
History
- 1982Origin
Koei's The Dragon and Princess (1982) is considered a precursor for its tactical turn-based combat on a separate screen; Fire Emblem (1990) set the model.
Tactical role-playing game (Wikipedia) - 1983
Koji Sumii's Bokosuka Wars lays the foundations of the genre, known in Japan as "simulation RPG".
Tactical role-playing game (Wikipedia) - 1990Fire Emblem: Shadow Dragon and the Blade of Light
Intelligent Systems' Fire Emblem joins ideas from Dragon Quest with turn-based strategy and becomes the genre's archetype. A character who runs out of health is usually lost forever.
Tactical role-playing game (Wikipedia) - 1992
Shining Force, for the Mega Drive, adds even more console role-playing elements.
Tactical role-playing game (Wikipedia) - 1994UFO: Enemy Unknown
UFO: Enemy Unknown (X-COM) alternates real-time base management with turn-based tactical missions, and defines the genre's Western PC variant.
UFO: Enemy Unknown (Wikipedia) - 1997Final Fantasy Tactics
Final Fantasy Tactics, with battles on 3D isometric fields and a job system, does the most to bring the genre to North America.
Tactical role-playing game (Wikipedia) - 2003
Nippon Ichi's Disgaea: Hour of Darkness mixes tile combat with coloured panels that alter the terrain and a randomly generated «Item World». Its success launches a whole franchise.
Disgaea: Hour of Darkness (Wikipedia) - 2012XCOM: Enemy Unknown
Firaxis's XCOM: Enemy Unknown reimagines X-COM with cover and permanent death, and revives interest in turn-based tactics.
XCOM: Enemy Unknown (Wikipedia) - 2025Pico-Saga
Pico-Saga brings the Fire Emblem formula to the PICO-8 fantasy console, with maps generated anew each game and playable in the browser.
Pico-Saga 1.0 (Lexaloffle BBS, PICO-8)
Reference games
1990
Fire Emblem: Shadow Dragon and the Blade of Light
You move your army across a tile map in turns that alternate with the enemy. Each class moves and fights differently, weapons wear out and a character who falls does not return for the rest of the campaign. It became the genre's archetype; it was not released outside Japan until 2020.
1994
UFO: Enemy Unknown
Known in North America as X-COM: UFO Defense, it alternates defending the planet from an alien invasion, in real time, with turn-based tactical missions where your soldiers spend time units to move and shoot. Those who fall do not return.
1997
Final Fantasy Tactics
Turn-based battles on three-dimensional isometric fields, where terrain and weather matter. A job system lets you turn each character into a warrior, a mage or other roles. It did the most to bring the genre to North America.
2012
XCOM: Enemy Unknown
A reimagining of UFO: Enemy Unknown. Your squad of up to six soldiers acts in turns and shelters behind full or half cover; soldiers can die for good.
2025
Pico-Saga
A turn-based tactical RPG inspired by Fire Emblem, with maps generated anew each game, a weapon triangle, terrain that changes movement and evasion, and castles to besiege.
Exercise
In the demo, edit the text map to open or close a pass between rocks and see how the AI's behaviour changes. Then add a fourth unit with its own numbers and discuss what role it plays in the party.