Skip to content
GameGenresEspañol
Strategy

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 screen

A 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

  1. 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 },
    };
  2. 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 }]);
  3. 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) });
      }
    });
  4. 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) } });
    });
  5. 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);
  6. 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);
    }
  7. 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));
    });
  8. 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);
    });
  9. 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);
  10. 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");
// #endregion

Design 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

  1. 1982
    OriginUtopia

    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)
  2. 1989
    Herzog 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)
  3. 1992
    Dune 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)
  4. 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)
  5. 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)
  6. 1997
    Age 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)
  7. 1998
    StarCraft

    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)
  8. 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)
  9. 2009
    0 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)
  10. 2013
    Littlewargame

    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

Mattel Electronics (Don Daglow). Intellivision

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

Technosoft. Mega Drive

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

Westwood Studios. MS-DOS, Amiga, Mega Drive

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

Ensemble Studios. Windows

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

Blizzard Entertainment. Windows, macOS

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.

2023

Retro Commander

Noble Master Games. navegador, Windows, macOS, Linux, Android, iOS

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.

Sources