
Role-playing
You take on the role of one or more characters defined by numbers (health, attack, skills) that grow as you explore the world and win battles. The challenge lies in deciding how they progress and how to make the most of what they can do in each situation.
Also known as: RPG, CRPG, role-playing video game
Defining mechanics
- Numerical statistics (health, magic, attack, defence, speed) that decide the outcome of every action through formulas and some chance.
- Character progression through experience, levels, equipment and new skills; it is what makes effort feel rewarded.
- Turn-based, pausable or real-time combat, with tactical decisions such as whom to attack, when to heal and when to spend limited resources.
- A party of characters with complementary roles (one takes hits, one heals, one deals magic damage).
- Exploration of a world with quests, characters, items and, often, a long story.
Gameplay loop
Explore, fight, earn experience and items, improve the character and venture into harder areas.
Our demo
Controls. Up and down arrows to choose the action, left and right to choose the enemy, space or Enter to confirm; keys 1 to 4 pick the action directly. With a mouse or on mobile, tap an enemy to target it and tap a menu option.
Open full screenA party of two heroes, a warrior and a mage, fights one to three enemies in turn-based battles in the style of Japanese RPGs. Winning gives them experience, they level up and their statistics grow, and each battle is a little harder than the last.
Made with Kaplay, in about 199 lines of JavaScript.
How it's made
Setup and controls
The game is created and virtual buttons are defined (up, down, left, right, ok) so the rest of the code does not depend on keys. The write function wraps text drawing with the system font.
// Virtual buttons: the game asks about "ok", not about a specific key. // No touch buttons: on mobile you tap the menu and the enemies directly. kaplay({ width: 640, height: 360, letterbox: true, pixelDensity, background: [24, 20, 44], buttons: { up: { keyboard: ["up", "w"] }, down: { keyboard: ["down", "s"] }, left: { keyboard: ["left", "a"] }, right: { keyboard: ["right", "d"] }, ok: { keyboard: ["space", "enter", "z"] }, }, }); const [WHITE, YELLOW, GREY, PANEL] = [rgb(240, 240, 255), rgb(255, 214, 90), rgb(150, 150, 185), rgb(42, 36, 74)]; const write = (txt, x, y, opts = {}) => drawText({ text: txt, pos: vec2(x, y), size: 16, font: FONT, color: WHITE, ...opts });Hero and enemy statistics
Each character is an object with numbers: health, magic, attack, defence, speed, level and experience, plus a skill with its MP cost. Enemies come from a table of types and grow 15 % stronger each battle. In an RPG, designing is mostly deciding these numbers.
// Each character is a handful of numbers. Those numbers, and how they grow, are the heart of the genre. function newParty() { return [ { name: "Ana", job: t({ es: "Guerrera", en: "Warrior" }), hp: 45, maxHp: 45, mp: 6, maxMp: 6, attack: 12, defense: 6, speed: 7, level: 1, xp: 0, color: rgb(90, 160, 255), x: 500, y: 110, skill: { name: t({ es: "Tajo", en: "Slash" }), cost: 3, mult: 2, hitsAll: false } }, { name: "Leo", job: t({ es: "Mago", en: "Mage" }), hp: 30, maxHp: 30, mp: 14, maxMp: 14, attack: 8, defense: 3, speed: 9, level: 1, xp: 0, color: rgb(230, 120, 220), x: 560, y: 175, skill: { name: t({ es: "Fuego", en: "Fire" }), cost: 4, mult: 1.5, hitsAll: true } }, ]; } const ENEMY_TYPES = [ { name: t({ es: "Limo", en: "Slime" }), hp: 18, attack: 9, defense: 2, speed: 4, xp: 4, shape: "circle", color: rgb(110, 200, 110) }, { name: t({ es: "Murciélago", en: "Bat" }), hp: 14, attack: 10, defense: 1, speed: 12, xp: 5, shape: "triangle", color: rgb(170, 110, 230) }, { name: t({ es: "Lobo", en: "Wolf" }), hp: 26, attack: 13, defense: 4, speed: 10, xp: 8, shape: "box", color: rgb(165, 165, 175) }, ]; // Between 1 and 3 enemies, 15 % stronger in every battle. function newEnemies(battle) { const count = randi(1, Math.min(3, battle + 1) + 1); const f = 1 + 0.15 * (battle - 1); return Array.from({ length: count }, (_, i) => { const type = choose(ENEMY_TYPES); const hp = Math.round(type.hp * f); return { ...type, hp, maxHp: hp, attack: Math.round(type.attack * f), enemy: true, x: 130 + (i % 2) * 110, y: 100 + i * 46 }; }); } let party, battle, potions; // the game state survives between battlesThe damage formula
damage = (attack × multiplier − defence ÷ 2) × a random factor between 0.85 and 1.15, with a minimum of 1, halved if the target is defending. The multiplier is 1 for a normal attack, 2 for Slash and 1.5 for Fire, which hits everyone. Subtracting defence makes extra attack pay off more against weak enemies, the minimum of 1 avoids useless hits, and the randomness keeps things from being fully predictable. Small changes here alter the whole balance.
// damage = (attack × multiplier − defense ÷ 2) × random factor between 0.85 and 1.15; minimum 1. // A defending target takes half. Change these numbers and you change the whole balance. function computeDamage(attacker, target, mult = 1) { const base = attacker.attack * mult - target.defense / 2; const damage = Math.max(1, Math.round(base * rand(0.85, 1.15))); return target.defending ? Math.ceil(damage / 2) : damage; }Turn order
At the start of each round, all living combatants are sorted by speed into a queue and act one after another. That is why speed is a tactical statistic, as it decides who hits or heals first. Defending lasts until the defender's next turn.
// At the start of each round, the living are sorted by speed: the fastest acts first. function nextTurn() { if (alive(enemies).length === 0) return win(); if (alive(party).length === 0) return go("end", battle - 1); if (queue.length === 0) queue = alive([...party, ...enemies]).sort((a, b) => b.speed - a.speed); current = queue.shift(); if (current.hp <= 0) return nextTurn(); current.defending = false; // defending lasts until your next turn if (current.enemy) { mode = "waiting"; wait(0.7, enemyTurn); } else { mode = "menu"; target = Math.min(target, alive(enemies).length - 1); message = t({ es: `Turno de ${current.name}. ¿Qué hace?`, en: `${current.name}'s turn. What now?` }); } } wait(0.8, nextTurn);The four actions
Attacking is free; the skill does more damage but spends MP, a resource that is not restored between battles except on level-up; the potion heals but there are few; defending halves the damage taken. The genre's appeal lies in no option always being the best.
// The four tactical decisions of every turn: attack, spend magic, heal or protect yourself. function act(i) { if (mode !== "menu") return; const h = current; const foe = alive(enemies)[target]; if (i === 0) { const d = computeDamage(h, foe); hit(foe, d); message = `${h.name} → ${foe.name}: ${d}`; } else if (i === 1) { const skill = h.skill; if (h.mp < skill.cost) return void (message = t({ es: "No te alcanza la magia (PM).", en: "Not enough magic (MP)." })); h.mp -= skill.cost; for (const e of skill.hitsAll ? alive(enemies) : [foe]) hit(e, computeDamage(h, e, skill.mult)); message = t({ es: `${h.name} usa ${skill.name}.`, en: `${h.name} uses ${skill.name}.` }); } else if (i === 2) { if (potions === 0) return void (message = t({ es: "No quedan pociones.", en: "No potions left." })); potions--; // The potion goes to the most wounded ally (relative to their maximum health). const wounded = alive(party).sort((a, b) => a.hp / a.maxHp - b.hp / b.maxHp)[0]; const heal = Math.min(25, wounded.maxHp - wounded.hp); wounded.hp += heal; popup(wounded, `+${heal}`, rgb(120, 240, 140)); message = t({ es: `${wounded.name} recupera ${heal} PV.`, en: `${wounded.name} recovers ${heal} HP.` }); } else { h.defending = true; message = t({ es: `${h.name} se defiende.`, en: `${h.name} defends.` }); } mode = "waiting"; wait(0.6, nextTurn); }Enemy AI
Enemies pick a living hero at random and attack them. It is the minimum needed for combat to work; a smarter AI, one that finishes off the weakest hero for instance, would make the game much harder.
// The simplest possible AI: attack a random living hero. function enemyTurn() { const foe = choose(alive(party)); const d = computeDamage(current, foe); hit(foe, d); const note = foe.defending ? t({ es: " (se defendió)", en: " (blocked)" }) : ""; message = `${current.name} → ${foe.name}: ${d}${note}`; wait(0.6, nextTurn); }Experience and levelling up
Every defeated enemy gives experience to the heroes still standing. At level × 10 points a hero levels up, which raises health, magic, attack, defence and speed and also fully heals. This progression, which turns effort into bigger numbers, is what defines the genre.
// Winning gives experience; level × 10 points means a level up, and the stats grow. function win() { mode = "victory"; const xp = enemies.reduce((sum, e) => sum + e.xp, 0); const lines = [t({ es: `¡Victoria! +${xp} de experiencia y +1 poción.`, en: `Victory! +${xp} experience and +1 potion.` })]; for (const h of alive(party)) { h.xp += xp; while (h.xp >= h.level * 10) { h.xp -= h.level * 10; h.level++; Object.assign(h, { maxHp: h.maxHp + 6, maxMp: h.maxMp + 2, attack: h.attack + 2, defense: h.defense + 1, speed: h.speed + 1 }); [h.hp, h.mp] = [h.maxHp, h.maxMp]; // levelling up also heals lines.push(t({ es: `${h.name} sube a nivel ${h.level}.`, en: `${h.name} reaches level ${h.level}.` })); } } for (const h of party) h.hp = Math.max(1, h.hp); // fallen heroes get up with 1 HP [potions, battle] = [potions + 1, battle + 1]; message = lines.join(" "); }Keyboard, mouse and touch
With the keyboard you move the menu cursor and the target and confirm. With a mouse or a finger, tapping an enemy targets it and tapping an option runs it; all it takes is comparing the click position with each element's position.
// Keyboard: up and down pick the action, left and right the enemy, space confirms. // Mouse or touch: clicking an enemy targets it; clicking the menu acts. const changeTarget = (d, n = alive(enemies).length) => n && (target = (target + d + n) % n); onButtonPress("up", () => (choice = (choice + 3) % 4)); onButtonPress("down", () => (choice = (choice + 1) % 4)); onButtonPress("left", () => changeTarget(-1)); onButtonPress("right", () => changeTarget(1)); onButtonPress("ok", () => (mode === "victory" ? go("game") : act(choice))); onKeyPress(["1", "2", "3", "4"], (key) => act(Number(key) - 1)); onMousePress(() => { if (mode === "victory") return go("game"); const m = mousePos(); alive(enemies).forEach((e, i) => m.dist(vec2(e.x, e.y)) < 32 && (target = i)); MENU.forEach((_, i) => m.x > 400 && m.y > 244 + i * 26 && m.y < 270 + i * 26 && act((choice = i))); });The interface
Everything is redrawn every frame from the state (message, fighters, health bars, party panel and menu). The numbers rule and the drawing only shows them, so changing the logic does not require touching the graphics. The numbers that float up on a hit or a heal give each decision the immediate feedback it needs.
// Everything is drawn every frame from the state: the numbers rule and the drawing shows them. function drawFighter(c, marked) { const op = c.hp > 0 ? 1 : 0.2; const p = vec2(c.x, c.y); if (c.shape === "circle") drawCircle({ pos: p, radius: 20, color: c.color, opacity: op }); else if (c.shape === "triangle") drawPolygon({ pts: [p.add(-26, -12), p.add(26, -12), p.add(0, 16)], color: c.color, opacity: op }); else if (c.shape === "box") drawRect({ pos: p.add(-24, -16), width: 48, height: 32, radius: 6, color: c.color, opacity: op }); else { drawRect({ pos: p.add(-12, -10), width: 24, height: 30, color: c.color, opacity: op }); drawCircle({ pos: p.add(0, -20), radius: 10, color: rgb(250, 220, 190), opacity: op }); if (c.defending) drawRect({ pos: p.add(-24, -12), width: 8, height: 28, color: YELLOW }); } drawRect({ pos: p.add(-24, 26), width: 48, height: 5, color: rgb(70, 40, 60) }); if (c.hp > 0) drawRect({ pos: p.add(-24, 26), width: (48 * c.hp) / c.maxHp, height: 5, color: rgb(120, 230, 120) }); if (marked) write("▼", c.x, c.y - 46, { anchor: "center", color: YELLOW }); } onUpdate(() => (popups = popups.filter((f) => (f.t += dt()) < 1))); onDraw(() => { drawRect({ pos: vec2(8, 6), width: 624, height: 46, radius: 6, color: PANEL }); write(message, 18, 12, { size: 15, width: 604 }); const mpLabel = t({ es: "PM", en: "MP" }); write(`${t({ es: "Batalla", en: "Battle" })} ${battle} · ${t({ es: "Pociones", en: "Potions" })}: ${potions}`, 628, 214, { size: 13, color: GREY, anchor: "topright" }); const living = alive(enemies); for (const e of enemies) drawFighter(e, mode === "menu" && e === living[target]); for (const h of party) drawFighter(h, mode === "menu" && h === current); drawRect({ pos: vec2(8, 236), width: 386, height: 118, radius: 6, color: PANEL }); party.forEach((h, i) => { const y = 248 + i * 52; write(`${h.name} · ${h.job} · ${t({ es: "Nv", en: "Lv" })} ${h.level} EXP ${h.xp}/${h.level * 10}`, 20, y, { color: h === current ? YELLOW : WHITE }); write(`${t({ es: "PV", en: "HP" })} ${h.hp}/${h.maxHp} ${mpLabel} ${h.mp}/${h.maxMp} ${t({ es: "ATQ", en: "ATK" })} ${h.attack} DEF ${h.defense} ${t({ es: "VEL", en: "SPD" })} ${h.speed}`, 20, y + 21, { size: 13, color: GREY }); }); drawRect({ pos: vec2(400, 236), width: 232, height: 118, radius: 6, color: PANEL }); if (mode === "victory") write(t({ es: "Espacio o clic:\nsiguiente batalla", en: "Space or click:\nnext battle" }), 416, 262, { color: YELLOW }); else MENU.forEach((o, i) => { const label = i === 1 && current && !current.enemy ? `${current.skill.name} (${current.skill.cost} ${mpLabel})` : o; const chosen = mode === "menu" && i === choice; write((chosen ? "▶ " : " ") + `${i + 1}. ${label}`, 412, 248 + i * 26, { color: mode !== "menu" ? GREY : chosen ? YELLOW : WHITE }); }); for (const f of popups) write(f.txt, f.x, f.y - f.t * 30, { anchor: "center", size: 20, color: f.color, opacity: 1 - f.t }); });Scenes
There are three scenes, title, game and end. The party lives outside the scenes, so it keeps its level from one battle to the next; newGame resets everything.
function screen(title, subtitle, next) { add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 50)), anchor("center")]); add([text(subtitle, { size: 16, font: FONT, align: "center", width: 560 }), pos(center().add(0, 30)), anchor("center"), color(200, 210, 255)]); onButtonPress("ok", next); onMousePress(next); } function newGame() { [party, battle, potions] = [newParty(), 1, 3]; go("game"); } scene("title", () => screen(t({ es: "Rol: combate por turnos", en: "Roleplaying: turn-based combat" }), t({ es: "Dirige a Ana y a Leo. En cada turno elige atacar, usar una habilidad (gasta PM), dar una poción o defenderte.\nFlechas y espacio, teclas 1–4, o haz clic en el menú y en los enemigos.\nPulsa espacio o haz clic para empezar.", en: "Lead Ana and Leo. Each turn, choose to attack, use a skill (costs MP), give a potion or defend.\nArrows and space, keys 1–4, or click the menu and the enemies.\nPress space or click to start.", }), newGame)); scene("end", (won, level = Math.max(...party.map((h) => h.level))) => screen(t({ es: "Tu grupo ha caído", en: "Your party has fallen" }), t({ es: `Batallas ganadas: ${won}. Nivel más alto: ${level}.\nPulsa espacio o haz clic para empezar de nuevo.`, en: `Battles won: ${won}. Highest level: ${level}.\nPress space or click to start again.`, }), newGame)); go("title");
What we simplified
- There is no map or exploration; only combat, which is where statistics and progression show.
- A single skill per hero, a single item type (the potion, which heals the most wounded ally) and an enemy AI that attacks at random.
- Characters are geometric shapes and there is no sound, equipment or shop.
To modify it
- Add a «Flee» action that succeeds with a probability that depends on the hero's speed compared with the fastest enemy's.
- Make Fire deal double damage to slimes and half to wolves (weaknesses and resistances).
- Improve the AI so enemies attack the hero with the least health, and compare how many battles you survive.
- Add a third hero, a healer with a skill that heals the whole party.
Full code
// Roleplaying demo
// Engine: Kaplay 3001 (https://kaplayjs.com). MIT licence.
import kaplay from "../../vendor/kaplay.mjs";
import { t, FONT, pixelDensity } from "../common.js";
// #region setup
// Virtual buttons: the game asks about "ok", not about a specific key.
// No touch buttons: on mobile you tap the menu and the enemies directly.
kaplay({
width: 640, height: 360, letterbox: true, pixelDensity, background: [24, 20, 44],
buttons: {
up: { keyboard: ["up", "w"] },
down: { keyboard: ["down", "s"] },
left: { keyboard: ["left", "a"] },
right: { keyboard: ["right", "d"] },
ok: { keyboard: ["space", "enter", "z"] },
},
});
const [WHITE, YELLOW, GREY, PANEL] = [rgb(240, 240, 255), rgb(255, 214, 90), rgb(150, 150, 185), rgb(42, 36, 74)];
const write = (txt, x, y, opts = {}) =>
drawText({ text: txt, pos: vec2(x, y), size: 16, font: FONT, color: WHITE, ...opts });
// #endregion
// #region stats
// Each character is a handful of numbers. Those numbers, and how they grow, are the heart of the genre.
function newParty() {
return [
{ name: "Ana", job: t({ es: "Guerrera", en: "Warrior" }), hp: 45, maxHp: 45, mp: 6, maxMp: 6,
attack: 12, defense: 6, speed: 7, level: 1, xp: 0, color: rgb(90, 160, 255), x: 500, y: 110,
skill: { name: t({ es: "Tajo", en: "Slash" }), cost: 3, mult: 2, hitsAll: false } },
{ name: "Leo", job: t({ es: "Mago", en: "Mage" }), hp: 30, maxHp: 30, mp: 14, maxMp: 14,
attack: 8, defense: 3, speed: 9, level: 1, xp: 0, color: rgb(230, 120, 220), x: 560, y: 175,
skill: { name: t({ es: "Fuego", en: "Fire" }), cost: 4, mult: 1.5, hitsAll: true } },
];
}
const ENEMY_TYPES = [
{ name: t({ es: "Limo", en: "Slime" }), hp: 18, attack: 9, defense: 2, speed: 4, xp: 4, shape: "circle", color: rgb(110, 200, 110) },
{ name: t({ es: "Murciélago", en: "Bat" }), hp: 14, attack: 10, defense: 1, speed: 12, xp: 5, shape: "triangle", color: rgb(170, 110, 230) },
{ name: t({ es: "Lobo", en: "Wolf" }), hp: 26, attack: 13, defense: 4, speed: 10, xp: 8, shape: "box", color: rgb(165, 165, 175) },
];
// Between 1 and 3 enemies, 15 % stronger in every battle.
function newEnemies(battle) {
const count = randi(1, Math.min(3, battle + 1) + 1);
const f = 1 + 0.15 * (battle - 1);
return Array.from({ length: count }, (_, i) => {
const type = choose(ENEMY_TYPES);
const hp = Math.round(type.hp * f);
return { ...type, hp, maxHp: hp, attack: Math.round(type.attack * f), enemy: true, x: 130 + (i % 2) * 110, y: 100 + i * 46 };
});
}
let party, battle, potions; // the game state survives between battles
// #endregion
// #region damage-formula
// damage = (attack × multiplier − defense ÷ 2) × random factor between 0.85 and 1.15; minimum 1.
// A defending target takes half. Change these numbers and you change the whole balance.
function computeDamage(attacker, target, mult = 1) {
const base = attacker.attack * mult - target.defense / 2;
const damage = Math.max(1, Math.round(base * rand(0.85, 1.15)));
return target.defending ? Math.ceil(damage / 2) : damage;
}
// #endregion
scene("game", () => {
const enemies = newEnemies(battle);
const alive = (list) => list.filter((c) => c.hp > 0);
const MENU = [t({ es: "Atacar", en: "Attack" }), t({ es: "Habilidad", en: "Skill" }), t({ es: "Poción", en: "Potion" }), t({ es: "Defender", en: "Defend" })];
let message = t({ es: "¡Aparecen enemigos!", en: "Enemies appear!" });
let mode = "waiting"; // waiting · menu · victory
let [current, choice, target, queue] = [null, 0, 0, []]; // target: index among living enemies
let popups = []; // numbers that float up on a hit or a heal
const popup = (c, txt, color) => popups.push({ x: c.x, y: c.y - 34, txt, color, t: 0 });
const hit = (c, damage) => { c.hp = Math.max(0, c.hp - damage); popup(c, String(damage), rgb(255, 110, 90)); };
// #region turn-order
// At the start of each round, the living are sorted by speed: the fastest acts first.
function nextTurn() {
if (alive(enemies).length === 0) return win();
if (alive(party).length === 0) return go("end", battle - 1);
if (queue.length === 0) queue = alive([...party, ...enemies]).sort((a, b) => b.speed - a.speed);
current = queue.shift();
if (current.hp <= 0) return nextTurn();
current.defending = false; // defending lasts until your next turn
if (current.enemy) {
mode = "waiting";
wait(0.7, enemyTurn);
} else {
mode = "menu";
target = Math.min(target, alive(enemies).length - 1);
message = t({ es: `Turno de ${current.name}. ¿Qué hace?`, en: `${current.name}'s turn. What now?` });
}
}
wait(0.8, nextTurn);
// #endregion
// #region actions
// The four tactical decisions of every turn: attack, spend magic, heal or protect yourself.
function act(i) {
if (mode !== "menu") return;
const h = current;
const foe = alive(enemies)[target];
if (i === 0) {
const d = computeDamage(h, foe);
hit(foe, d);
message = `${h.name} → ${foe.name}: ${d}`;
} else if (i === 1) {
const skill = h.skill;
if (h.mp < skill.cost) return void (message = t({ es: "No te alcanza la magia (PM).", en: "Not enough magic (MP)." }));
h.mp -= skill.cost;
for (const e of skill.hitsAll ? alive(enemies) : [foe]) hit(e, computeDamage(h, e, skill.mult));
message = t({ es: `${h.name} usa ${skill.name}.`, en: `${h.name} uses ${skill.name}.` });
} else if (i === 2) {
if (potions === 0) return void (message = t({ es: "No quedan pociones.", en: "No potions left." }));
potions--;
// The potion goes to the most wounded ally (relative to their maximum health).
const wounded = alive(party).sort((a, b) => a.hp / a.maxHp - b.hp / b.maxHp)[0];
const heal = Math.min(25, wounded.maxHp - wounded.hp);
wounded.hp += heal;
popup(wounded, `+${heal}`, rgb(120, 240, 140));
message = t({ es: `${wounded.name} recupera ${heal} PV.`, en: `${wounded.name} recovers ${heal} HP.` });
} else {
h.defending = true;
message = t({ es: `${h.name} se defiende.`, en: `${h.name} defends.` });
}
mode = "waiting";
wait(0.6, nextTurn);
}
// #endregion
// #region enemy-ai
// The simplest possible AI: attack a random living hero.
function enemyTurn() {
const foe = choose(alive(party));
const d = computeDamage(current, foe);
hit(foe, d);
const note = foe.defending ? t({ es: " (se defendió)", en: " (blocked)" }) : "";
message = `${current.name} → ${foe.name}: ${d}${note}`;
wait(0.6, nextTurn);
}
// #endregion
// #region xp-levels
// Winning gives experience; level × 10 points means a level up, and the stats grow.
function win() {
mode = "victory";
const xp = enemies.reduce((sum, e) => sum + e.xp, 0);
const lines = [t({ es: `¡Victoria! +${xp} de experiencia y +1 poción.`, en: `Victory! +${xp} experience and +1 potion.` })];
for (const h of alive(party)) {
h.xp += xp;
while (h.xp >= h.level * 10) {
h.xp -= h.level * 10;
h.level++;
Object.assign(h, { maxHp: h.maxHp + 6, maxMp: h.maxMp + 2, attack: h.attack + 2, defense: h.defense + 1, speed: h.speed + 1 });
[h.hp, h.mp] = [h.maxHp, h.maxMp]; // levelling up also heals
lines.push(t({ es: `${h.name} sube a nivel ${h.level}.`, en: `${h.name} reaches level ${h.level}.` }));
}
}
for (const h of party) h.hp = Math.max(1, h.hp); // fallen heroes get up with 1 HP
[potions, battle] = [potions + 1, battle + 1];
message = lines.join(" ");
}
// #endregion
// #region input
// Keyboard: up and down pick the action, left and right the enemy, space confirms.
// Mouse or touch: clicking an enemy targets it; clicking the menu acts.
const changeTarget = (d, n = alive(enemies).length) => n && (target = (target + d + n) % n);
onButtonPress("up", () => (choice = (choice + 3) % 4));
onButtonPress("down", () => (choice = (choice + 1) % 4));
onButtonPress("left", () => changeTarget(-1));
onButtonPress("right", () => changeTarget(1));
onButtonPress("ok", () => (mode === "victory" ? go("game") : act(choice)));
onKeyPress(["1", "2", "3", "4"], (key) => act(Number(key) - 1));
onMousePress(() => {
if (mode === "victory") return go("game");
const m = mousePos();
alive(enemies).forEach((e, i) => m.dist(vec2(e.x, e.y)) < 32 && (target = i));
MENU.forEach((_, i) => m.x > 400 && m.y > 244 + i * 26 && m.y < 270 + i * 26 && act((choice = i)));
});
// #endregion
// #region interface
// Everything is drawn every frame from the state: the numbers rule and the drawing shows them.
function drawFighter(c, marked) {
const op = c.hp > 0 ? 1 : 0.2;
const p = vec2(c.x, c.y);
if (c.shape === "circle") drawCircle({ pos: p, radius: 20, color: c.color, opacity: op });
else if (c.shape === "triangle") drawPolygon({ pts: [p.add(-26, -12), p.add(26, -12), p.add(0, 16)], color: c.color, opacity: op });
else if (c.shape === "box") drawRect({ pos: p.add(-24, -16), width: 48, height: 32, radius: 6, color: c.color, opacity: op });
else {
drawRect({ pos: p.add(-12, -10), width: 24, height: 30, color: c.color, opacity: op });
drawCircle({ pos: p.add(0, -20), radius: 10, color: rgb(250, 220, 190), opacity: op });
if (c.defending) drawRect({ pos: p.add(-24, -12), width: 8, height: 28, color: YELLOW });
}
drawRect({ pos: p.add(-24, 26), width: 48, height: 5, color: rgb(70, 40, 60) });
if (c.hp > 0) drawRect({ pos: p.add(-24, 26), width: (48 * c.hp) / c.maxHp, height: 5, color: rgb(120, 230, 120) });
if (marked) write("▼", c.x, c.y - 46, { anchor: "center", color: YELLOW });
}
onUpdate(() => (popups = popups.filter((f) => (f.t += dt()) < 1)));
onDraw(() => {
drawRect({ pos: vec2(8, 6), width: 624, height: 46, radius: 6, color: PANEL });
write(message, 18, 12, { size: 15, width: 604 });
const mpLabel = t({ es: "PM", en: "MP" });
write(`${t({ es: "Batalla", en: "Battle" })} ${battle} · ${t({ es: "Pociones", en: "Potions" })}: ${potions}`, 628, 214, { size: 13, color: GREY, anchor: "topright" });
const living = alive(enemies);
for (const e of enemies) drawFighter(e, mode === "menu" && e === living[target]);
for (const h of party) drawFighter(h, mode === "menu" && h === current);
drawRect({ pos: vec2(8, 236), width: 386, height: 118, radius: 6, color: PANEL });
party.forEach((h, i) => {
const y = 248 + i * 52;
write(`${h.name} · ${h.job} · ${t({ es: "Nv", en: "Lv" })} ${h.level} EXP ${h.xp}/${h.level * 10}`, 20, y, { color: h === current ? YELLOW : WHITE });
write(`${t({ es: "PV", en: "HP" })} ${h.hp}/${h.maxHp} ${mpLabel} ${h.mp}/${h.maxMp} ${t({ es: "ATQ", en: "ATK" })} ${h.attack} DEF ${h.defense} ${t({ es: "VEL", en: "SPD" })} ${h.speed}`, 20, y + 21, { size: 13, color: GREY });
});
drawRect({ pos: vec2(400, 236), width: 232, height: 118, radius: 6, color: PANEL });
if (mode === "victory") write(t({ es: "Espacio o clic:\nsiguiente batalla", en: "Space or click:\nnext battle" }), 416, 262, { color: YELLOW });
else MENU.forEach((o, i) => {
const label = i === 1 && current && !current.enemy ? `${current.skill.name} (${current.skill.cost} ${mpLabel})` : o;
const chosen = mode === "menu" && i === choice;
write((chosen ? "▶ " : " ") + `${i + 1}. ${label}`, 412, 248 + i * 26, { color: mode !== "menu" ? GREY : chosen ? YELLOW : WHITE });
});
for (const f of popups) write(f.txt, f.x, f.y - f.t * 30, { anchor: "center", size: 20, color: f.color, opacity: 1 - f.t });
});
// #endregion
});
// #region scenes
function screen(title, subtitle, next) {
add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 50)), anchor("center")]);
add([text(subtitle, { size: 16, font: FONT, align: "center", width: 560 }), pos(center().add(0, 30)), anchor("center"), color(200, 210, 255)]);
onButtonPress("ok", next);
onMousePress(next);
}
function newGame() {
[party, battle, potions] = [newParty(), 1, 3];
go("game");
}
scene("title", () => screen(t({ es: "Rol: combate por turnos", en: "Roleplaying: turn-based combat" }), t({
es: "Dirige a Ana y a Leo. En cada turno elige atacar, usar una habilidad (gasta PM), dar una poción o defenderte.\nFlechas y espacio, teclas 1–4, o haz clic en el menú y en los enemigos.\nPulsa espacio o haz clic para empezar.",
en: "Lead Ana and Leo. Each turn, choose to attack, use a skill (costs MP), give a potion or defend.\nArrows and space, keys 1–4, or click the menu and the enemies.\nPress space or click to start.",
}), newGame));
scene("end", (won, level = Math.max(...party.map((h) => h.level))) => screen(t({ es: "Tu grupo ha caído", en: "Your party has fallen" }), t({
es: `Batallas ganadas: ${won}. Nivel más alto: ${level}.\nPulsa espacio o haz clic para empezar de nuevo.`,
en: `Battles won: ${won}. Highest level: ${level}.\nPress space or click to start again.`,
}), newGame));
go("title");
// #endregionDesign challenges
- Balancing the formulas and the progression curve so that levelling up is noticeable, without the player becoming unbeatable or having to repeat fights endlessly to move on.
- Making every combat decision matter; if attacking is always best, the menu is pointless.
- Producing a lot of content (text, quests, items, enemies), which makes it one of the most expensive genres to make.
- Showing a lot of information (statistics, inventory, maps) without overwhelming the player.
History
- 1975Origindnd
In the mid-1970s, on PLATO university systems, games such as pedit5 and dnd brought the ideas of the Dungeons & Dragons tabletop game to the computer.
dnd (video game) (Wikipedia) - 1980Akalabeth: World of Doom
Richard Garriott's Akalabeth combines an overhead map with first-person dungeons and foreshadows the Ultima series.
Akalabeth: World of Doom (Wikipedia) - 1981Wizardry: Proving Grounds of the Mad Overlord
Wizardry introduces the party of adventurers with different classes and menu-driven turn-based combat; it will inspire Japanese RPGs.
Wizardry: Proving Grounds of the Mad Overlord (Wikipedia) - 1986Dragon Quest
Dragon Quest simplifies the RPG for consoles and sets the template for Japanese RPGs, with experience, levels and menu combat.
Dragon Quest (Wikipedia) - 1987Final Fantasy
Final Fantasy shows the party side-on facing the enemies and lets you choose their classes, a format the genre will repeat for years.
Final Fantasy (Wikipedia) - 1997Final Fantasy VII
Final Fantasy VII, with 3D characters, makes the Japanese RPG popular worldwide.
Final Fantasy VII (Wikipedia) - 1998Baldur's Gate
Baldur's Gate applies Dungeons & Dragons rules with pausable real-time combat and revives the Western computer RPG.
Baldur's Gate (Wikipedia)
Reference games
1975
dnd
One of the first computer role-playing games, made on the PLATO university system. You go down a dungeon, fight monsters and improve your character; it is also the first game with what would later be called a boss.
1980
Akalabeth: World of Doom
Richard Garriott programmed it in BASIC as a teenager. You travel an overhead map and go down into first-person wireframe dungeons; it is the direct ancestor of the Ultima series.
1981
Wizardry: Proving Grounds of the Mad Overlord
You form a party of six adventurers of different classes and guide it floor by floor through a first-person maze, with turn-based battles chosen from a menu. It is the first truly party-based role-playing game.
1986
Dragon Quest
A lone hero travels an overhead world and fights turn-based battles, choosing to fight, cast a spell, use an item or flee. Winning gives experience and gold, and levelling up opens more dangerous areas. It set the template for Japanese RPGs.
1987
Final Fantasy
You pick four characters from six classes (fighter, thief, white mage, black mage…) and keep them together for the whole game. Its turn-based battles show the party side-on, facing the enemies, a format the genre adopted for years.
1997
Final Fantasy VII
A Japanese RPG with 3D characters over pre-rendered backgrounds and battles with time bars that fill up on their own. You combine materia to customise the party's abilities. It made Japanese RPGs popular outside Japan.
1998
Baldur's Gate
You lead a party of up to six characters under Advanced Dungeons & Dragons rules, which the game computes for you, in real-time battles you can pause to give orders. It revived the Western computer RPG.
2013
Candy Box 2
It starts as a candy counter and gradually opens up into a small role-playing game drawn in ASCII art. You buy gear, complete quests against monsters and earn items that unlock new areas. Its code is free software (GPLv3).
2015
Undertale
A role-playing game in which each battle mixes turns with a small bullet hell, where you dodge attacks by moving a heart that stands for your soul. You can also spare monsters instead of defeating them.
Exercise
In the demo, change the damage formula (for example, subtract the full defence instead of half) and the experience each level requires; play three battles and note how the difficulty changes. Then design on paper a third hero class whose skill makes an action that hardly anyone picks today worth choosing.
Subgenres
- Action role-playing gameA role-playing game where you directly control your character's attacks and movement in real-time combat while you level up, improve stats and find better gear. The challenge blends skill at the controls with choices about how your character grows.
- Dungeon crawlerA role-playing game where you work through enclosed labyrinths level by level, fighting monsters, avoiding traps and collecting treasure. The challenge is managing your character's or party's health and resources to reach ever deeper.
- Japanese RPGA role-playing game in the Japanese tradition where you lead a party of predefined characters through a long, dramatic story, with combat that is often turn-based and an anime-like look. The challenge is growing the party and combining its abilities to beat ever-tougher enemies.
- Massively multiplayer online RPGAn online role-playing game where your character shares a persistent world with thousands of other players. You level up through quests and combat, and the biggest challenges, such as raids, require coordinating with other people as a group.
- Monster-taming gameA role-playing game where you capture or recruit creatures, train them and have them fight for you. The challenge is building and growing a team whose strengths cover each other's weaknesses.
- RoguelikeA role-playing game in which you descend through a dungeon generated anew in every game, moving turn by turn on a grid. If your character dies you lose everything and start again in a different dungeon, so every step calls for thought.
- Tactical RPGA 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.
- Western RPGA role-playing game in the American and European tradition where you create and customise your character and develop them your own way while freely exploring a large world. Your choices and stats open or close paths, quests and endings.