
Real-time strategy
A strategy game where you gather resources, build a base, produce units and direct attacks while your rivals do the same, with no turns or pauses. The challenge is deciding quickly and handling economy and combat at the same time.
Also known as: RTS, real time strategy
Defining mechanics
- Gathering resources with worker units that, once sent, keep working on their own.
- Spending those resources on buildings and units, which cost money and production time.
- Selecting units with the mouse (a click or a box) and giving them orders that depend on where you click (move, attack, gather).
- Combat between units with different roles that counter one another, often with fog of war and a tech tree.
Gameplay loop
Gather, spend on units, defend and scout, and attack when you have the upper hand, all at once and while the rival does the same.
Our demo
Controls. Click a unit or drag a box to select. With units selected, click (or right-click) the ground to move, a crystal to gather or an enemy to attack. Top buttons, or keys 1 and 2, train workers and soldiers; Escape deselects. On mobile, tap and drag with your finger.
Open full screenA short match against an enemy base. Your workers gather crystals, you spend them on soldiers and you direct the attack with the mouse, while the rival sends ever larger groups at you. Everything happens at once and with no turns, which is what defines the genre.
Made with Kaplay, in about 196 lines of JavaScript.
How it's made
Setup and numbers
The constants are the game's balance: what each unit costs, how long it takes to train, how much a worker carries, how much health the bases have and how often the rival attacks. Changing just one of these numbers changes which strategy pays off.
kaplay({ width: 640, height: 360, letterbox: true, pixelDensity, background: [44, 66, 48], // Everything is played with the mouse or a finger; keys are only shortcuts. buttons: { start: { keyboard: ["space", "enter"] }, worker: { keyboard: ["1"] }, soldier: { keyboard: ["2"] }, deselect: { keyboard: ["escape"] }, }, }); const HUD = 34; // height of the top bar const COST = { worker: 40, soldier: 60 }; const TRAIN_TIME = 2; // seconds to train one unit const CARRY = 10; // crystals a worker brings back per trip const MINE_TIME = 1; // seconds to fill the bag const SIGHT = 90; // idle fighters attack enemies closer than this const BASE_HP = { player: 200, enemy: 150 }; const WAVE_EVERY = 20, MAX_WAVE = 3; // enemy attacks: how often, and the largest group const STATS = { worker: { hp: 6, speed: 85, radius: 6 }, soldier: { hp: 14, speed: 65, radius: 8, damage: 2, range: 26, rate: 0.6 }, raider: { hp: 10, speed: 55, radius: 8, damage: 2, range: 26, rate: 0.7 }, };The map
Two bases face each other, with four crystal fields closer to yours. Each field runs out, so the economy is not endless and there comes a point when attacking is the better choice.
// Two bases face each other; crystal fields lie closer to yours. const HOME = vec2(60, 200); const ENEMY_HOME = vec2(585, 200); const addBase = (p, team) => add([rect(44, 44), pos(p), anchor("center"), color(team === "player" ? rgb(70, 130, 230) : rgb(200, 70, 70)), outline(3, rgb(20, 20, 20)), "building", team, { team, hp: BASE_HP[team], maxHp: BASE_HP[team], size: 22 }]); const [playerBase] = [addBase(HOME, "player"), addBase(ENEMY_HOME, "enemy")]; for (const p of [vec2(165, 95), vec2(195, 125), vec2(165, 300), vec2(200, 275)]) add([polygon([vec2(0, -11), vec2(9, 0), vec2(0, 11), vec2(-9, 0)]), pos(p), color(110, 230, 240), outline(2, rgb(20, 60, 70)), "crystal", { amount: 150 }]);The units
A unit is a shape with a few pieces of data (team, health and its current order). The circle is a worker and the square a soldier. The green ring marks the selection and the cyan dot the load a worker carries.
// A unit is a shape plus a few numbers: team, health and its current order. function addUnit(kind, team, p, order = null) { const s = STATS[kind]; const look = kind === "worker" ? circle(s.radius) : rect(s.radius * 2, s.radius * 2); const tint = team === "player" ? (kind === "worker" ? rgb(170, 210, 255) : rgb(60, 110, 230)) : rgb(220, 70, 60); const u = add([look, pos(p), anchor("center"), color(tint), outline(2, rgb(20, 20, 20)), "unit", team, kind, { kind, team, hp: s.hp, maxHp: s.hp, size: s.radius, order, cooldown: 0, cargo: 0, selected: false, shot: 0, shotAt: null }]); u.onDraw(() => { if (u.selected) drawCircle({ radius: u.size + 5, fill: false, outline: { width: 2, color: rgb(140, 255, 150) } }); if (u.cargo) drawCircle({ pos: vec2(0, -11), radius: 3, color: rgb(110, 230, 240) }); if (u.shot > 0) drawLine({ p1: vec2(0, 0), p2: u.shotAt.sub(u.pos), width: 2, color: rgb(255, 240, 150) }); }); return u; } for (const [i, p] of [vec2(100, 170), vec2(100, 200), vec2(100, 230)].entries()) addUnit("worker", "player", p, { type: "gather", node: get("crystal")[i % 2 ? 2 : 0] }); addUnit("soldier", "player", vec2(120, 200)); for (const p of [vec2(530, 170), vec2(530, 230), vec2(545, 200)]) addUnit("raider", "enemy", p); // Health bars, drawn above every unit. add([z(45)]).onDraw(() => { for (const o of [...get("unit"), ...get("building")].filter((o) => o.hp < o.maxHp)) { drawRect({ pos: o.pos.add(-o.size, -o.size - 8), width: o.size * 2, height: 3, color: rgb(40, 20, 20) }); drawRect({ pos: o.pos.add(-o.size, -o.size - 8), width: (o.size * 2 * o.hp) / o.maxHp, height: 3, color: rgb(120, 230, 120) }); } });Mouse selection
This is the gesture the genre invented to command many units: a click picks one and a box picks several. If the box catches soldiers, only they are kept, so you do not drag workers away from their job by accident. A click is told apart from a drag by the distance between where the button was pressed and where it was released.
// Click a unit to select it, or drag a box around several of yours. // A box with soldiers in it keeps only the soldiers: workers stay at work. const mine = () => get("player").filter((o) => o.is("unit")); const selection = () => mine().filter((u) => u.selected); const clearSelection = () => mine().forEach((u) => (u.selected = false)); let dragStart = null; onMousePress("left", () => (dragStart = mousePos().y > HUD ? mousePos() : null)); onMouseRelease("left", () => { if (!dragStart) return; const [a, b] = [dragStart, mousePos()]; dragStart = null; const picked = a.dist(b) > 8 ? mine().filter((u) => u.pos.x >= Math.min(a.x, b.x) && u.pos.x <= Math.max(a.x, b.x) && u.pos.y >= Math.min(a.y, b.y) && u.pos.y <= Math.max(a.y, b.y)) : mine().filter((u) => u.pos.dist(b) <= u.size + 5).slice(0, 1); if (!picked.length && a.dist(b) <= 8) return selection().length && giveOrder(b); // click on the ground = order const army = picked.filter((u) => u.kind !== "worker"); clearSelection(); (army.length ? army : picked).forEach((u) => (u.selected = true)); }); onMousePress("right", () => giveOrder(mousePos())); onButtonPress("deselect", clearSelection); add([z(50)]).onDraw(() => { const m = mousePos(); if (!dragStart || dragStart.dist(m) <= 8) return; drawRect({ pos: vec2(Math.min(dragStart.x, m.x), Math.min(dragStart.y, m.y)), width: Math.abs(m.x - dragStart.x), height: Math.abs(m.y - dragStart.y), fill: false, outline: { width: 1, color: rgb(140, 255, 150) } }); });Context-sensitive orders
The same click means different things depending on what is under it and who receives it. On a crystal, workers gather; on an enemy, soldiers attack; on the ground, everyone moves, spread over a small grid so they do not pile up on one spot.
// The same click means different things depending on what is under the // cursor and who receives it: gather, attack or simply move. function giveOrder(p) { const foe = get("enemy").find((o) => o.pos.dist(p) <= o.size + 6); const node = get("crystal").find((c) => c.pos.dist(p) <= 16); selection().forEach((u, i) => { if (u.kind === "worker" && node) u.order = { type: "gather", node }; else if (u.kind === "soldier" && foe) u.order = { type: "attack", target: foe }; // Spread the group so they do not all pile onto one point. else u.order = { type: "move", to: p.add(((i % 3) - 1) * 18, (Math.floor(i / 3) - 1) * 18) }; }); add([circle(6), pos(p), anchor("center"), color(node ? rgb(110, 230, 240) : foe ? rgb(255, 90, 80) : rgb(140, 255, 150)), opacity(0.6), lifespan(0.3, { fade: 0.3 })]); } // Step towards a point; returns true once there. function moveToward(u, p, stopAt = 2) { const d = p.sub(u.pos); if (d.len() > stopAt) u.pos = u.pos.add(d.unit().scale(Math.min(d.len(), STATS[u.kind].speed * dt()))); return d.len() <= stopAt; } const nearest = (list, p, max = Infinity) => list.reduce((best, o) => (o.pos.dist(p) < max && (!best || o.pos.dist(p) < best.pos.dist(p)) ? o : best), null);Gathering
Each worker repeats a cycle without supervision: go to the crystal, fill the bag, return to base and unload. This automation is key to the genre, because it frees your attention for combat. If the field runs out, the worker looks for the nearest one.
// Workers loop on their own: walk to the crystal, mine, carry the load home. function updateWorker(u) { const o = u.order; if (!o) return; if (o.type === "move") return moveToward(u, o.to) && (u.order = null); if (u.cargo > 0) { if (moveToward(u, HOME, 28)) [crystals, u.cargo] = [crystals + u.cargo, 0]; // deliver the load return; } if (!o.node.exists()) o.node = nearest(get("crystal"), u.pos); // field used up: find another one if (!o.node) return (u.order = null); if (!moveToward(u, o.node.pos, 14)) return; o.timer = (o.timer ?? 0) + dt(); if (o.timer < MINE_TIME) return; o.timer = 0; u.cargo = Math.min(CARRY, o.node.amount); o.node.amount -= u.cargo; if (o.node.amount <= 0) destroy(o.node); }Producing units
Spending is the other half of the economy. Each unit costs crystals and time, and waits in a queue. More workers, which pay off later, or soldiers right now? That is the underlying decision in every real-time strategy match.
// Spending is the other half of the economy: each unit costs crystals and time. const notice = add([text("", { size: 14, font: FONT }), pos(320, 346), anchor("center"), z(60)]); function train(kind) { if (crystals < COST[kind]) { notice.text = t({ es: "No tienes cristales suficientes", en: "Not enough crystals" }); return wait(1.2, () => (notice.text = "")); } crystals -= COST[kind]; queue.push(kind); } onUpdate(() => { if (!queue.length || (trainTimer += dt()) < TRAIN_TIME) return; trainTimer = 0; const rally = HOME.add(55, ((spawned++ % 5) - 2) * 20); // where new units wait addUnit(queue.shift(), "player", HOME.add(26, 0), { type: "move", to: rally }); }); add([rect(width(), HUD), color(16, 22, 18), z(40)]); const hud = add([text("", { size: 14, font: FONT }), pos(10, 10), z(41)]); onUpdate(() => { const busy = queue.length ? ` ${t({ es: "Entrenando", en: "Training" })}: ${queue.length}` : ""; hud.text = `${t({ es: "Cristales", en: "Crystals" })}: ${crystals} ${t({ es: "Unidades", en: "Units" })}: ${mine().length}${busy}`; }); [["worker", t({ es: "1 Obrero", en: "1 Worker" })], ["soldier", t({ es: "2 Soldado", en: "2 Soldier" })]].forEach(([kind, label], i) => { const b = add([rect(128, 26, { radius: 4 }), pos(370 + i * 136, 4), color(40, 56, 46), outline(2, rgb(110, 230, 240)), area(), z(41)]); b.add([text(`${label} (${COST[kind]})`, { size: 13, font: FONT }), pos(64, 13), anchor("center")]); b.onClick(() => train(kind)); onButtonPress(kind, () => train(kind)); });Combat
Soldiers and raiders share one rule, which is to chase the target until it is in range and strike every so often. Units with no order attack whatever comes close on their own, and neighbouring units push each other slightly so they do not merge into one point. Destroying a base ends the match.
// Soldiers and raiders share one rule: chase the target until in range, // then strike every `rate` seconds. Idle fighters defend themselves. const foesOf = (team) => get(team === "player" ? "enemy" : "player"); function updateFighter(u) { const s = STATS[u.kind]; u.cooldown -= dt(), u.shot -= dt(); let target = u.order?.type === "attack" && u.order.target.exists() ? u.order.target : null; if (u.order?.type === "attack" && !target) u.order = null; if (!target && u.order?.type !== "move") target = nearest(foesOf(u.team), u.pos, SIGHT); if (!target && u.order?.type === "assault") target = playerBase; if (!target) return u.order?.type === "move" && moveToward(u, u.order.to) && (u.order = null); if (u.pos.dist(target.pos) > s.range + target.size) return moveToward(u, target.pos, 0); if (u.cooldown > 0) return; u.cooldown = s.rate; [u.shot, u.shotAt] = [0.08, target.pos.clone()]; // a short flash shows who hits whom target.hp -= s.damage; if (target.hp > 0) return; destroy(target); if (target.is("building")) go("end", target.team === "enemy"); } onUpdate("unit", (u) => { // Neighbours push each other apart, so a group does not collapse into one point. for (const o of get("unit")) if (o !== u && u.pos.dist(o.pos) < u.size + o.size) u.pos = u.pos.add(u.pos.sub(o.pos).unit().scale(40 * dt())); u.kind === "worker" ? updateWorker(u) : updateFighter(u); });The opponent
The simplest possible AI. Every so often the enemy base sends a group of raiders at your base, and each group is a little bigger than the last. That is enough to create pressure, because if all you do is gather, you lose.
// A very simple opponent: every few seconds its base sends a group of // raiders at your base, and each group is a little bigger than the last. let waveSize = 1; loop(WAVE_EVERY, () => { for (let i = 0; i < waveSize; i++) addUnit("raider", "enemy", ENEMY_HOME.add(-30, (i - 1) * 18), { type: "assault" }); waveSize = Math.min(waveSize + 1, MAX_WAVE); }, undefined, true);Scenes
Three scenes (title, game and end). Restarting with go() removes every object and timer from the previous match.
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, 230, 210)]); onButtonPress("start", next); onClick(next); } scene("title", () => screen( t({ es: "Estrategia en tiempo real", en: "Real-time strategy" }), t({ es: "Haz clic en una unidad o arrastra un recuadro para seleccionar. Luego haz clic en el suelo para mover, en un cristal para recolectar (obreros) o en un enemigo para atacar (soldados).\nCompra unidades arriba con los cristales. Destruye la base roja antes de que caiga la tuya.\nHaz clic o pulsa espacio para empezar.", en: "Click a unit or drag a box to select. Then click the ground to move, a crystal to gather (workers) or an enemy to attack (soldiers).\nBuy units at the top with crystals. Destroy the red base before yours falls.\nClick or press space to start.", }), () => go("game"), ), ); scene("end", (won) => screen( won ? t({ es: "¡Base enemiga destruida!", en: "Enemy base destroyed!" }) : t({ es: "Tu base cayó", en: "Your base fell" }), t({ es: "Haz clic o pulsa espacio para jugar otra vez.", en: "Click or press space to play again." }), () => go("game"), )); go("title");
What we simplified
- A single resource and two unit types; there are no buildings to construct and no tech tree.
- There is no fog of war; the whole map is visible from the start.
- Units move in a straight line to their destination, with no pathfinding, because the map has no obstacles.
- The enemy AI does not run an economy; it only sends waves at your base at fixed intervals.
To modify it
- Add fog of war, so you only see enemies that are close to your units.
- Create a third unit type, for example a long-range archer with little health, and see how the best mix of troops changes.
- Give the rival an economy, with its own gathering workers, so its waves depend on what it collects.
Full code
// Real-time strategy 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: [44, 66, 48],
// Everything is played with the mouse or a finger; keys are only shortcuts.
buttons: {
start: { keyboard: ["space", "enter"] },
worker: { keyboard: ["1"] },
soldier: { keyboard: ["2"] },
deselect: { keyboard: ["escape"] },
},
});
const HUD = 34; // height of the top bar
const COST = { worker: 40, soldier: 60 };
const TRAIN_TIME = 2; // seconds to train one unit
const CARRY = 10; // crystals a worker brings back per trip
const MINE_TIME = 1; // seconds to fill the bag
const SIGHT = 90; // idle fighters attack enemies closer than this
const BASE_HP = { player: 200, enemy: 150 };
const WAVE_EVERY = 20, MAX_WAVE = 3; // enemy attacks: how often, and the largest group
const STATS = {
worker: { hp: 6, speed: 85, radius: 6 },
soldier: { hp: 14, speed: 65, radius: 8, damage: 2, range: 26, rate: 0.6 },
raider: { hp: 10, speed: 55, radius: 8, damage: 2, range: 26, rate: 0.7 },
};
// #endregion
scene("game", () => {
let crystals = 100, trainTimer = 0, spawned = 0;
const queue = []; // units waiting to be trained
// #region map
// Two bases face each other; crystal fields lie closer to yours.
const HOME = vec2(60, 200);
const ENEMY_HOME = vec2(585, 200);
const addBase = (p, team) =>
add([rect(44, 44), pos(p), anchor("center"), color(team === "player" ? rgb(70, 130, 230) : rgb(200, 70, 70)),
outline(3, rgb(20, 20, 20)), "building", team, { team, hp: BASE_HP[team], maxHp: BASE_HP[team], size: 22 }]);
const [playerBase] = [addBase(HOME, "player"), addBase(ENEMY_HOME, "enemy")];
for (const p of [vec2(165, 95), vec2(195, 125), vec2(165, 300), vec2(200, 275)])
add([polygon([vec2(0, -11), vec2(9, 0), vec2(0, 11), vec2(-9, 0)]), pos(p), color(110, 230, 240), outline(2, rgb(20, 60, 70)), "crystal", { amount: 150 }]);
// #endregion
// #region units
// A unit is a shape plus a few numbers: team, health and its current order.
function addUnit(kind, team, p, order = null) {
const s = STATS[kind];
const look = kind === "worker" ? circle(s.radius) : rect(s.radius * 2, s.radius * 2);
const tint = team === "player" ? (kind === "worker" ? rgb(170, 210, 255) : rgb(60, 110, 230)) : rgb(220, 70, 60);
const u = add([look, pos(p), anchor("center"), color(tint), outline(2, rgb(20, 20, 20)), "unit", team, kind,
{ kind, team, hp: s.hp, maxHp: s.hp, size: s.radius, order, cooldown: 0, cargo: 0, selected: false, shot: 0, shotAt: null }]);
u.onDraw(() => {
if (u.selected) drawCircle({ radius: u.size + 5, fill: false, outline: { width: 2, color: rgb(140, 255, 150) } });
if (u.cargo) drawCircle({ pos: vec2(0, -11), radius: 3, color: rgb(110, 230, 240) });
if (u.shot > 0) drawLine({ p1: vec2(0, 0), p2: u.shotAt.sub(u.pos), width: 2, color: rgb(255, 240, 150) });
});
return u;
}
for (const [i, p] of [vec2(100, 170), vec2(100, 200), vec2(100, 230)].entries()) addUnit("worker", "player", p, { type: "gather", node: get("crystal")[i % 2 ? 2 : 0] });
addUnit("soldier", "player", vec2(120, 200));
for (const p of [vec2(530, 170), vec2(530, 230), vec2(545, 200)]) addUnit("raider", "enemy", p);
// Health bars, drawn above every unit.
add([z(45)]).onDraw(() => {
for (const o of [...get("unit"), ...get("building")].filter((o) => o.hp < o.maxHp)) {
drawRect({ pos: o.pos.add(-o.size, -o.size - 8), width: o.size * 2, height: 3, color: rgb(40, 20, 20) });
drawRect({ pos: o.pos.add(-o.size, -o.size - 8), width: (o.size * 2 * o.hp) / o.maxHp, height: 3, color: rgb(120, 230, 120) });
}
});
// #endregion
// #region selection
// Click a unit to select it, or drag a box around several of yours.
// A box with soldiers in it keeps only the soldiers: workers stay at work.
const mine = () => get("player").filter((o) => o.is("unit"));
const selection = () => mine().filter((u) => u.selected);
const clearSelection = () => mine().forEach((u) => (u.selected = false));
let dragStart = null;
onMousePress("left", () => (dragStart = mousePos().y > HUD ? mousePos() : null));
onMouseRelease("left", () => {
if (!dragStart) return;
const [a, b] = [dragStart, mousePos()];
dragStart = null;
const picked = a.dist(b) > 8 ? mine().filter((u) => u.pos.x >= Math.min(a.x, b.x) && u.pos.x <= Math.max(a.x, b.x)
&& u.pos.y >= Math.min(a.y, b.y) && u.pos.y <= Math.max(a.y, b.y)) : mine().filter((u) => u.pos.dist(b) <= u.size + 5).slice(0, 1);
if (!picked.length && a.dist(b) <= 8) return selection().length && giveOrder(b); // click on the ground = order
const army = picked.filter((u) => u.kind !== "worker");
clearSelection();
(army.length ? army : picked).forEach((u) => (u.selected = true));
});
onMousePress("right", () => giveOrder(mousePos()));
onButtonPress("deselect", clearSelection);
add([z(50)]).onDraw(() => {
const m = mousePos();
if (!dragStart || dragStart.dist(m) <= 8) return;
drawRect({ pos: vec2(Math.min(dragStart.x, m.x), Math.min(dragStart.y, m.y)), width: Math.abs(m.x - dragStart.x),
height: Math.abs(m.y - dragStart.y), fill: false, outline: { width: 1, color: rgb(140, 255, 150) } });
});
// #endregion
// #region orders
// The same click means different things depending on what is under the
// cursor and who receives it: gather, attack or simply move.
function giveOrder(p) {
const foe = get("enemy").find((o) => o.pos.dist(p) <= o.size + 6);
const node = get("crystal").find((c) => c.pos.dist(p) <= 16);
selection().forEach((u, i) => {
if (u.kind === "worker" && node) u.order = { type: "gather", node };
else if (u.kind === "soldier" && foe) u.order = { type: "attack", target: foe };
// Spread the group so they do not all pile onto one point.
else u.order = { type: "move", to: p.add(((i % 3) - 1) * 18, (Math.floor(i / 3) - 1) * 18) };
});
add([circle(6), pos(p), anchor("center"), color(node ? rgb(110, 230, 240) : foe ? rgb(255, 90, 80) : rgb(140, 255, 150)), opacity(0.6), lifespan(0.3, { fade: 0.3 })]);
}
// Step towards a point; returns true once there.
function moveToward(u, p, stopAt = 2) {
const d = p.sub(u.pos);
if (d.len() > stopAt) u.pos = u.pos.add(d.unit().scale(Math.min(d.len(), STATS[u.kind].speed * dt())));
return d.len() <= stopAt;
}
const nearest = (list, p, max = Infinity) =>
list.reduce((best, o) => (o.pos.dist(p) < max && (!best || o.pos.dist(p) < best.pos.dist(p)) ? o : best), null);
// #endregion
// #region economy
// Workers loop on their own: walk to the crystal, mine, carry the load home.
function updateWorker(u) {
const o = u.order;
if (!o) return;
if (o.type === "move") return moveToward(u, o.to) && (u.order = null);
if (u.cargo > 0) {
if (moveToward(u, HOME, 28)) [crystals, u.cargo] = [crystals + u.cargo, 0]; // deliver the load
return;
}
if (!o.node.exists()) o.node = nearest(get("crystal"), u.pos); // field used up: find another one
if (!o.node) return (u.order = null);
if (!moveToward(u, o.node.pos, 14)) return;
o.timer = (o.timer ?? 0) + dt();
if (o.timer < MINE_TIME) return;
o.timer = 0;
u.cargo = Math.min(CARRY, o.node.amount);
o.node.amount -= u.cargo;
if (o.node.amount <= 0) destroy(o.node);
}
// #endregion
// #region production
// Spending is the other half of the economy: each unit costs crystals and time.
const notice = add([text("", { size: 14, font: FONT }), pos(320, 346), anchor("center"), z(60)]);
function train(kind) {
if (crystals < COST[kind]) {
notice.text = t({ es: "No tienes cristales suficientes", en: "Not enough crystals" });
return wait(1.2, () => (notice.text = ""));
}
crystals -= COST[kind];
queue.push(kind);
}
onUpdate(() => {
if (!queue.length || (trainTimer += dt()) < TRAIN_TIME) return;
trainTimer = 0;
const rally = HOME.add(55, ((spawned++ % 5) - 2) * 20); // where new units wait
addUnit(queue.shift(), "player", HOME.add(26, 0), { type: "move", to: rally });
});
add([rect(width(), HUD), color(16, 22, 18), z(40)]);
const hud = add([text("", { size: 14, font: FONT }), pos(10, 10), z(41)]);
onUpdate(() => {
const busy = queue.length ? ` ${t({ es: "Entrenando", en: "Training" })}: ${queue.length}` : "";
hud.text = `${t({ es: "Cristales", en: "Crystals" })}: ${crystals} ${t({ es: "Unidades", en: "Units" })}: ${mine().length}${busy}`;
});
[["worker", t({ es: "1 Obrero", en: "1 Worker" })], ["soldier", t({ es: "2 Soldado", en: "2 Soldier" })]].forEach(([kind, label], i) => {
const b = add([rect(128, 26, { radius: 4 }), pos(370 + i * 136, 4), color(40, 56, 46), outline(2, rgb(110, 230, 240)), area(), z(41)]);
b.add([text(`${label} (${COST[kind]})`, { size: 13, font: FONT }), pos(64, 13), anchor("center")]);
b.onClick(() => train(kind));
onButtonPress(kind, () => train(kind));
});
// #endregion
// #region combat
// Soldiers and raiders share one rule: chase the target until in range,
// then strike every `rate` seconds. Idle fighters defend themselves.
const foesOf = (team) => get(team === "player" ? "enemy" : "player");
function updateFighter(u) {
const s = STATS[u.kind];
u.cooldown -= dt(), u.shot -= dt();
let target = u.order?.type === "attack" && u.order.target.exists() ? u.order.target : null;
if (u.order?.type === "attack" && !target) u.order = null;
if (!target && u.order?.type !== "move") target = nearest(foesOf(u.team), u.pos, SIGHT);
if (!target && u.order?.type === "assault") target = playerBase;
if (!target) return u.order?.type === "move" && moveToward(u, u.order.to) && (u.order = null);
if (u.pos.dist(target.pos) > s.range + target.size) return moveToward(u, target.pos, 0);
if (u.cooldown > 0) return;
u.cooldown = s.rate;
[u.shot, u.shotAt] = [0.08, target.pos.clone()]; // a short flash shows who hits whom
target.hp -= s.damage;
if (target.hp > 0) return;
destroy(target);
if (target.is("building")) go("end", target.team === "enemy");
}
onUpdate("unit", (u) => {
// Neighbours push each other apart, so a group does not collapse into one point.
for (const o of get("unit")) if (o !== u && u.pos.dist(o.pos) < u.size + o.size) u.pos = u.pos.add(u.pos.sub(o.pos).unit().scale(40 * dt()));
u.kind === "worker" ? updateWorker(u) : updateFighter(u);
});
// #endregion
// #region enemy-ai
// A very simple opponent: every few seconds its base sends a group of
// raiders at your base, and each group is a little bigger than the last.
let waveSize = 1;
loop(WAVE_EVERY, () => {
for (let i = 0; i < waveSize; i++) addUnit("raider", "enemy", ENEMY_HOME.add(-30, (i - 1) * 18), { type: "assault" });
waveSize = Math.min(waveSize + 1, MAX_WAVE);
}, undefined, true);
// #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, 230, 210)]);
onButtonPress("start", next);
onClick(next);
}
scene("title", () =>
screen(
t({ es: "Estrategia en tiempo real", en: "Real-time strategy" }),
t({
es: "Haz clic en una unidad o arrastra un recuadro para seleccionar. Luego haz clic en el suelo para mover, en un cristal para recolectar (obreros) o en un enemigo para atacar (soldados).\nCompra unidades arriba con los cristales. Destruye la base roja antes de que caiga la tuya.\nHaz clic o pulsa espacio para empezar.",
en: "Click a unit or drag a box to select. Then click the ground to move, a crystal to gather (workers) or an enemy to attack (soldiers).\nBuy units at the top with crystals. Destroy the red base before yours falls.\nClick or press space to start.",
}),
() => go("game"),
),
);
scene("end", (won) => screen(
won ? t({ es: "¡Base enemiga destruida!", en: "Enemy base destroyed!" }) : t({ es: "Tu base cayó", en: "Your base fell" }),
t({ es: "Haz clic o pulsa espacio para jugar otra vez.", en: "Click or press space to play again." }),
() => go("game"),
));
go("title");
// #endregionDesign challenges
- An interface that lets you command dozens of units without fighting the mouse (selection, groups and shortcuts).
- Pathfinding and group movement without traffic jams, with many units at once and without hurting performance.
- Balancing units and factions so that no single strategy beats all the others.
- A rival AI that plays by the same rules as the player and is a challenge without cheating.
History
- 1982OriginUtopia
Utopia (1982), with timed rounds, is considered the earliest ancestor; Herzog Zwei (1989) is often cited as the first true game of the genre.
Real-time strategy (Wikipedia) - 1989Herzog Zwei
Herzog Zwei, for the Mega Drive, joins strategy with real-time action. You only pilot one ship, but from it you buy units and give them orders, a control scheme that foreshadows point-and-click.
Real-time strategy (Wikipedia) - 1992Dune II
Dune II brings together the foundations the genre still uses, such as moving units with the mouse and gathering resources. Westwood's Brett Sperry coins the term "real-time strategy" to market it.
Real-time strategy (Wikipedia) - 1994
Warcraft: Orcs & Humans takes the genre into fantasy, and its buildings, such as farms, represent a whole society rather than just an army.
Real-time strategy (Wikipedia) - 1995
Command & Conquer and its follow-up Red Alert (1996) are the most popular early games in the genre. The "tank rush", attacking en masse very early, comes from them.
Real-time strategy (Wikipedia) - 1997Age of Empires
Age of Empires slows the pace and adds ideas from Civilization, with a civilisation that advances through technological ages.
Real-time strategy (Wikipedia) - 1998StarCraft
StarCraft pits three very different races against each other and becomes an international phenomenon; it is still played in professional leagues.
Real-time strategy (Wikipedia) - 2002
Around 2002, with Warcraft III, 3D becomes the norm. A mod of that game, Defense of the Ancients (2003), gives rise to MOBAs.
Real-time strategy (Wikipedia) - 20090 A.D.
Wildfire Games releases the source code of 0 A.D. under the GPL, making it a game in the genre that anyone can download, study and modify.
0 A.D. (Wikipedia) - 2013Littlewargame
Littlewargame, a simple real-time strategy game made in HTML5 with multiplayer, runs entirely in the browser.
Littlewargame - an RTS with multiplayer and map editor (HTML5 Game Devs Forum)
Reference games
1982
Utopia
Two players each rule an island over timed rounds. With gold bars you build houses, schools, factories, hospitals and crops to house, feed and content a growing population; if you fail, rebels appear. It is credited with starting the genre.
1989
Herzog Zwei
You pilot a ship that transforms into a robot, buy ground units, airlift them and give them orders to capture bases in a real-time duel against another commander. It is considered the forerunner of the genre.
1992
Dune II
You lead one of three houses fighting over a desert planet. Your harvesters collect spice, the refinery turns it into credits, and with those you raise buildings and buy troops you move with the mouse. It set the genre's template.
1997
Age of Empires
You lead an ancient civilisation from the Stone Age on, gathering food, wood, stone and gold, and advance through ages that unlock new troops and technologies. A slower pace than other games in the genre.
1998
StarCraft
Three very different races (Terran, Zerg and Protoss), each with its own units and way of playing, gather minerals and gas to fight each other. Its multiplayer became a professional esport.
2010
0 A.D.
Free and open-source historical real-time strategy. You build a base, develop an economy, train an army and research technologies, while your settlement grows from village to town to city.
2013
Littlewargame
A simple medieval fantasy RTS made in HTML5 that runs entirely in the browser, with multiplayer for up to six people.
2023
Retro Commander
Classic-style post-apocalyptic real-time strategy, with skirmishes against the AI, a comic-book campaign and cross-platform multiplayer. It is played with a mouse or a touch screen.
Exercise
In the demo, change the soldier's cost and the time between waves, and test whether training more workers early pays off. Then draw on paper the tech tree of a game in the genre you know and discuss which decisions it forces on you.