Skip to content
GameGenresEspañol

You start with a small, weak deck and, during play, buy or earn new cards to improve it. The challenge is choosing what to add and what to cut so that every card you draw works in your favour.

Also known as: deck-building game, deckbuilding game

Defining mechanics

  • A small starting deck of basic cards, the same for everyone or set by character.
  • The three-pile cycle: each turn you draw a hand, play cards and discard them, and when the draw pile runs out the discard pile is shuffled into a new draw pile.
  • Getting cards during play, by buying them from a shared market (in tabletop games) or picking them as rewards after each fight (in many video games).
  • A per-turn resource, such as coins, energy or mana, that limits how many cards you can play.
  • Removing weak cards ("thinning" the deck) so the good ones are drawn more often.

Gameplay loop

Draw a hand, spend your energy on the best plays and discard; between fights, choose which card to add or remove, knowing it changes every hand to come.

Our demo

Controls. Mouse or finger only. Tap a card to play it and «End turn» to pass. The E key also ends the turn, and space or Enter start the game.

Open full screen

Turn-based fights against three enemies in a row with a starting deck of ten cards (attack, defend, draw). Each turn you get three energy and draw five cards, you see what the enemy is about to do and, after each victory, you pick one of three new cards for your deck.

Made with Kaplay, in about 196 lines of JavaScript.

How it's made

  1. Setup

    The game is created and the basic rules are set, which are health, energy per turn, hand size, the hand limit and healing between fights. Changing these numbers changes the genre more than any graphics.

    kaplay({
      width: 640,
      height: 360,
      letterbox: true, pixelDensity,
      background: [28, 26, 40],
      // Played with the mouse or a finger; the keyboard only has shortcuts.
      buttons: { start: { keyboard: ["space", "enter"] }, endTurn: { keyboard: ["e"] } },
    });
    
    const MAX_HP = 50;
    const ENERGY_PER_TURN = 3;
    const HAND_SIZE = 5;
    const HAND_LIMIT = 8; // you cannot hold more than this
    const HEAL_BETWEEN_FIGHTS = 10;
  2. Cards are data

    Each card is an object with its cost and effects (damage, hits, block, draw, energy). A single function plays every card, so adding a new one means adding a line. The starting deck has ten weak, predictable cards, because improving it is the game itself.

    // A card is just data: a cost in energy and some effects. The same function
    // plays every card, so adding a new one means adding a line here.
    const CARDS = {
      strike: { name: { es: "Golpe", en: "Strike" }, cost: 1, damage: 6 },
      defend: { name: { es: "Defensa", en: "Defend" }, cost: 1, block: 5 },
      think: { name: { es: "Pensar", en: "Think" }, cost: 1, draw: 2 },
      heavy: { name: { es: "Golpe fuerte", en: "Heavy blow" }, cost: 2, damage: 14 },
      twin: { name: { es: "Doble golpe", en: "Twin strike" }, cost: 1, damage: 4, hits: 2 },
      wall: { name: { es: "Muro", en: "Shield wall" }, cost: 2, block: 13 },
      guard: { name: { es: "Golpe y guardia", en: "Strike and guard" }, cost: 1, damage: 5, block: 4 },
      insight: { name: { es: "Intuición", en: "Insight" }, cost: 0, draw: 2 },
      surge: { name: { es: "Impulso", en: "Surge" }, cost: 0, energy: 1, draw: 1 },
    };
    // Starting deck: 10 weak, predictable cards. Improving it is the game.
    const STARTER = [..."sssssddddt"].map((c) => ({ s: "strike", d: "defend", t: "think" })[c]);
    const REWARD_POOL = ["heavy", "twin", "wall", "guard", "insight", "surge"];
    
    function describe(card) {
      const parts = [];
      if (card.damage) parts.push(t({ es: "Daño ", en: "Damage " }) + card.damage + (card.hits ? `×${card.hits}` : ""));
      if (card.block) parts.push(t({ es: "Bloqueo ", en: "Block " }) + card.block);
      if (card.energy) parts.push(t({ es: `+${card.energy} energía`, en: `+${card.energy} energy` }));
      if (card.draw) parts.push(t({ es: "Roba ", en: "Draw " }) + card.draw);
      return parts.join("\n");
    }
  3. Enemies with visible intent

    Each enemy repeats a pattern of intents and the next one is shown before your turn. Knowing it will attack for 16 turns spending energy on block into an informed decision rather than a gamble. That information turns the randomness of the draw into a problem you can solve.

    // Each enemy repeats a pattern of intents. The intent is shown BEFORE you
    // play: knowing it will hit for 16 is what makes choosing block a decision.
    const ENEMIES = [
      { name: { es: "Mancha", en: "Blob" }, hp: 24, pattern: [{ attack: 6 }, { attack: 6 }, { block: 6 }] },
      { name: { es: "Caballero", en: "Knight" }, hp: 36, pattern: [{ attack: 10 }, { block: 10 }, { attack: 7, hits: 2 }] },
      { name: { es: "Guardián", en: "Guardian" }, hp: 52, pattern: [{ block: 12 }, { attack: 16 }, { attack: 6, hits: 3 }] },
    ];
    const intentText = (i) =>
      i.attack
        ? t({ es: "Va a atacar: ", en: "Will attack: " }) + i.attack + (i.hits ? `×${i.hits}` : "")
        : t({ es: "Va a defenderse: ", en: "Will defend: " }) + i.block;
  4. Shuffling with Fisher–Yates

    The array is walked from the end and each position is swapped with a randomly chosen one among those left (itself included). It is a linear algorithm and makes every possible order equally likely. The usual trick sort(() => Math.random() - 0.5) looks like shuffling but produces biased orders, and in a card game that shows.

    // Fisher–Yates: walk the array from the end and swap each position with a
    // random one at or before it. Every order is equally likely, which naive
    // tricks such as sort(() => Math.random() - 0.5) do not guarantee.
    function shuffle(a) {
      for (let i = a.length - 1; i > 0; i--) {
        const j = Math.floor(Math.random() * (i + 1));
        [a[i], a[j]] = [a[j], a[i]];
      }
      return a;
    }
  5. Draw, hand and discard

    Cards move between three piles. You draw from the draw pile into your hand and, when played or at the end of the turn, they go to the discard pile. When the draw pile is empty, the discard pile is shuffled into a new draw pile. That is why a new card takes a while to show up, and why a smaller deck makes your best cards come up more often.

    // Three piles: draw, hand and discard. When the draw pile runs out, the
    // discard pile is shuffled and becomes the new draw pile.
    function draw(n) {
      for (let i = 0; i < n && hand.length < HAND_LIMIT; i++) {
        if (drawPile.length === 0) {
          if (discard.length === 0) break;
          drawPile = shuffle(discard);
          discard = [];
        }
        hand.push(drawPile.pop());
      }
    }
  6. Playing a card

    Playing a card deducts its energy cost, sends it to the discard pile and applies its effects. Block soaks damage before health, both yours and the enemy's.

    const hit = (target, amount) => {
      const absorbed = Math.min(target.block, amount); // block soaks damage first
      target.block -= absorbed;
      target.hp -= amount - absorbed;
    };
    function play(card) {
      if (phase !== "player" || card.cost > energy) return;
      energy -= card.cost;
      hand.splice(hand.indexOf(card), 1);
      discard.push(card);
      for (let h = 0; h < (card.hits ?? 1) && card.damage; h++) hit(enemy, card.damage);
      player.block += card.block ?? 0;
      energy += card.energy ?? 0;
      if (card.draw) draw(card.draw);
      if (enemy.hp <= 0) return win();
      renderHand();
    }
  7. Turns

    At the start of your turn your block is cleared, energy refills and you draw five cards. When you end it, whatever you did not play is discarded and the enemy carries out its intent. Limited energy forces you to choose between attacking and protecting yourself, and that choice in every hand is what shapes the genre.

    // Your turn: block resets, energy refills, you draw 5. When you end it,
    // the unplayed cards go to discard and the enemy carries out its intent.
    function startTurn() {
      phase = "player";
      player.block = 0;
      energy = ENERGY_PER_TURN;
      draw(HAND_SIZE);
      renderHand();
    }
    function endTurn() {
      if (phase !== "player") return;
      phase = "enemy";
      discard.push(...hand);
      hand = [];
      renderHand();
      wait(0.5, () => {
        const intent = enemy.pattern[turn % enemy.pattern.length];
        enemy.block = 0;
        if (intent.attack) for (let h = 0; h < (intent.hits ?? 1); h++) hit(player, intent.attack);
        enemy.block += intent.block ?? 0;
        shake(intent.attack ? 4 : 0);
        turn++;
        if (player.hp <= 0) return go("end", false, fight);
        wait(0.4, startTurn);
      });
    }
    function startFight() {
      fight++;
      enemy = { ...ENEMIES[fight], name: t(ENEMIES[fight].name), block: 0, maxHp: ENEMIES[fight].hp };
      turn = 0;
      drawPile = shuffle([...deck]); // every fight starts with the whole deck
      [hand, discard] = [[], []];
      startTurn();
    }
  8. Picking one card out of three

    After each victory three random cards are offered and you can take one or skip the reward. Skipping is valid, because every weak card you add makes your good cards come up less often. Building the deck is about deciding what goes in and what does not.

    // After each victory: pick 1 of 3 random cards, or skip. Skipping is valid,
    // because every weak card you add makes your best cards come up less often.
    function win() {
      phase = "reward";
      hand = [];
      renderHand();
      if (fight === ENEMIES.length - 1) return wait(0.5, () => go("end", true, fight + 1, deck.length));
      const offer = shuffle([...REWARD_POOL]).slice(0, 3);
      const layer = add([rect(width(), height()), color(0, 0, 0), opacity(0.75), z(50), "reward"]);
      layer.add([text(t({ es: `¡Venciste a ${enemy.name}! Elige una carta para tu mazo`, en: `You beat ${enemy.name}! Pick a card for your deck` }), { size: 20, font: FONT }), pos(320, 60), anchor("center")]);
      offer.forEach((id, i) => cardView({ id, ...CARDS[id] }, 130 + i * 130, 100, 110, 140, layer, pick));
      const skip = layer.add([rect(140, 34, { radius: 6 }), pos(250, 280), color(70, 70, 90), area()]);
      skip.add([text(t({ es: "Saltar", en: "Skip" }), { size: 16, font: FONT }), pos(70, 17), anchor("center")]);
      skip.onClick(() => pick(null));
      function pick(card) {
        if (card) deck.push(card);
        destroyAll("reward");
        player.hp = Math.min(MAX_HP, player.hp + HEAL_BETWEEN_FIGHTS);
        startFight();
      }
    }
  9. Interface

    The hand is rebuilt whenever it changes and the texts update every frame. Cards you cannot afford are dimmed, and the pile counter lets you work out what might come up in the next turns.

    // The hand is rebuilt every time it changes; the texts update every frame.
    add([rect(50, 70, { radius: 6 }), pos(110, 130), anchor("center"), color(90, 160, 240)]);
    const enemyBody = add([rect(70, 80, { radius: 6 }), pos(480, 125), anchor("center"), color(210, 80, 90)]);
    const playerInfo = add([text("", { size: 14, font: FONT, align: "center" }), pos(110, 188), anchor("center")]);
    const enemyInfo = add([text("", { size: 14, font: FONT, align: "center" }), pos(480, 188), anchor("center")]);
    const intentInfo = add([text("", { size: 15, font: FONT }), pos(480, 55), anchor("center"), color(255, 200, 120)]);
    const energyInfo = add([text("", { size: 16, font: FONT }), pos(110, 40), anchor("center"), color(255, 230, 120)]);
    const piles = add([text("", { size: 13, font: FONT }), pos(10, 228), color(190, 190, 220)]);
    const endButton = add([rect(120, 32, { radius: 6 }), pos(510, 212), color(70, 110, 80), area()]);
    endButton.add([text(t({ es: "Terminar turno", en: "End turn" }), { size: 15, font: FONT }), pos(60, 16), anchor("center")]);
    endButton.onClick(endTurn);
    onButtonPress("endTurn", endTurn);
    onUpdate(() => {
      playerInfo.text = `${t({ es: "Tú", en: "You" })}\n${t({ es: "Vida", en: "HP" })} ${player.hp}/${MAX_HP}` + (player.block ? ` · ${t({ es: "Bloqueo", en: "Block" })} ${player.block}` : "");
      enemyInfo.text = `${enemy.name}\n${t({ es: "Vida", en: "HP" })} ${Math.max(0, enemy.hp)}/${enemy.maxHp}` + (enemy.block ? ` · ${t({ es: "Bloqueo", en: "Block" })} ${enemy.block}` : "");
      intentInfo.text = phase === "player" ? intentText(enemy.pattern[turn % enemy.pattern.length]) : "";
      energyInfo.text = `${t({ es: "Energía", en: "Energy" })}: ${energy}/${ENERGY_PER_TURN}`;
      piles.text = `${t({ es: "Robo", en: "Draw" })}: ${drawPile.length}   ${t({ es: "Descarte", en: "Discard" })}: ${discard.length}   ${t({ es: "Mazo", en: "Deck" })}: ${deck.length}   ${t({ es: "Combate", en: "Fight" })} ${fight + 1}/${ENEMIES.length}`;
      enemyBody.opacity = enemy.hp > 0 ? 1 : 0.3;
      endButton.opacity = phase === "player" ? 1 : 0.4;
    });
    function cardView(card, x, y, w, h, parent, onPick) {
      const usable = phase === "reward" || card.cost <= energy;
      const c = parent.add([rect(w, h, { radius: 6 }), pos(x, y), color(card.damage ? rgb(120, 60, 60) : rgb(60, 80, 120)), outline(2, usable ? rgb(240, 230, 200) : rgb(80, 80, 90)), opacity(usable ? 1 : 0.55), area(), "card"]);
      c.add([circle(10), pos(12, 12), anchor("center"), color(255, 220, 90)]);
      c.add([text(String(card.cost), { size: 13, font: FONT }), pos(12, 12), anchor("center"), color(30, 30, 30)]);
      c.add([text(t(card.name), { size: 12, font: FONT, width: w - 8, align: "center" }), pos(w / 2, 34), anchor("center")]);
      c.add([text(describe(card), { size: 12, font: FONT, width: w - 8, align: "center" }), pos(w / 2, h * 0.68), anchor("center"), color(230, 230, 240)]);
      c.onClick(() => onPick(card));
    }
    function renderHand() {
      destroyAll("card");
      const w = Math.min(82, Math.floor(460 / Math.max(1, hand.length)) - 4);
      const x0 = 320 - (hand.length * (w + 4)) / 2;
      hand.forEach((card, i) => cardView(card, x0 + i * (w + 4), 250, w, 104, getTreeRoot(), play));
    }
  10. Scenes

    There are three scenes, title, game and end. go() moves between them, and going back to game deals a fresh starting deck.

    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("start", next);
      onClick(next);
    }
    
    scene("title", () =>
      screen(
        t({ es: "Construcción de mazos", en: "Deckbuilder" }),
        t({
          es: "Toca una carta para jugarla: cuesta energía (el número amarillo). Mira qué va a hacer el enemigo y decide si atacar o protegerte.\nVence a 3 enemigos; tras cada victoria eliges una carta nueva.\nHaz clic o pulsa espacio para empezar.",
          en: "Tap a card to play it: it costs energy (the yellow number). Check what the enemy is about to do and decide whether to attack or protect yourself.\nBeat 3 enemies; after each victory you pick a new card.\nClick or press space to start.",
        }),
        () => go("game"),
      ),
    );
    
    scene("end", (won, fights, deckSize) =>
      screen(
        won ? t({ es: "¡Venciste a los tres!", en: "You beat all three!" }) : t({ es: "Has caído", en: "You fell" }),
        (won ? t({ es: `Mazo final: ${deckSize} cartas.`, en: `Final deck: ${deckSize} cards.` }) : t({ es: `Caíste en el combate ${fights + 1}.`, en: `You fell in fight ${fights + 1}.` })) +
          t({ es: " Haz clic para repetir.", en: " Click to play again." }),
        () => go("game"),
      ),
    );
    
    go("title");

What we simplified

  • Cards are rectangles with text, with no artwork or animations.
  • There is no map, shop or way to remove cards from the deck; you can only add a card or skip the reward.
  • The enemy follows a fixed pattern instead of picking its intent at random, and there are no effects lasting several turns (poison, strength, weakness).
  • Rewards per fight, energy and successive enemies come from roguelike deckbuilders such as Slay the Spire. In the tabletop model, such as Dominion, cards are bought from a shared market.

To modify it

  • Add an option to the reward to remove a card from the deck instead of adding one, and compare which deck works better.
  • Create an effect that lasts several turns, such as poison that damages the enemy at the start of each of its turns.
  • Replace an enemy's fixed pattern with weighted random intents and think about how it changes reading the fight.
Full code
// Deckbuilder 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: [28, 26, 40],
  // Played with the mouse or a finger; the keyboard only has shortcuts.
  buttons: { start: { keyboard: ["space", "enter"] }, endTurn: { keyboard: ["e"] } },
});

const MAX_HP = 50;
const ENERGY_PER_TURN = 3;
const HAND_SIZE = 5;
const HAND_LIMIT = 8; // you cannot hold more than this
const HEAL_BETWEEN_FIGHTS = 10;
// #endregion

// #region cards
// A card is just data: a cost in energy and some effects. The same function
// plays every card, so adding a new one means adding a line here.
const CARDS = {
  strike: { name: { es: "Golpe", en: "Strike" }, cost: 1, damage: 6 },
  defend: { name: { es: "Defensa", en: "Defend" }, cost: 1, block: 5 },
  think: { name: { es: "Pensar", en: "Think" }, cost: 1, draw: 2 },
  heavy: { name: { es: "Golpe fuerte", en: "Heavy blow" }, cost: 2, damage: 14 },
  twin: { name: { es: "Doble golpe", en: "Twin strike" }, cost: 1, damage: 4, hits: 2 },
  wall: { name: { es: "Muro", en: "Shield wall" }, cost: 2, block: 13 },
  guard: { name: { es: "Golpe y guardia", en: "Strike and guard" }, cost: 1, damage: 5, block: 4 },
  insight: { name: { es: "Intuición", en: "Insight" }, cost: 0, draw: 2 },
  surge: { name: { es: "Impulso", en: "Surge" }, cost: 0, energy: 1, draw: 1 },
};
// Starting deck: 10 weak, predictable cards. Improving it is the game.
const STARTER = [..."sssssddddt"].map((c) => ({ s: "strike", d: "defend", t: "think" })[c]);
const REWARD_POOL = ["heavy", "twin", "wall", "guard", "insight", "surge"];

function describe(card) {
  const parts = [];
  if (card.damage) parts.push(t({ es: "Daño ", en: "Damage " }) + card.damage + (card.hits ? `×${card.hits}` : ""));
  if (card.block) parts.push(t({ es: "Bloqueo ", en: "Block " }) + card.block);
  if (card.energy) parts.push(t({ es: `+${card.energy} energía`, en: `+${card.energy} energy` }));
  if (card.draw) parts.push(t({ es: "Roba ", en: "Draw " }) + card.draw);
  return parts.join("\n");
}
// #endregion

// #region enemies
// Each enemy repeats a pattern of intents. The intent is shown BEFORE you
// play: knowing it will hit for 16 is what makes choosing block a decision.
const ENEMIES = [
  { name: { es: "Mancha", en: "Blob" }, hp: 24, pattern: [{ attack: 6 }, { attack: 6 }, { block: 6 }] },
  { name: { es: "Caballero", en: "Knight" }, hp: 36, pattern: [{ attack: 10 }, { block: 10 }, { attack: 7, hits: 2 }] },
  { name: { es: "Guardián", en: "Guardian" }, hp: 52, pattern: [{ block: 12 }, { attack: 16 }, { attack: 6, hits: 3 }] },
];
const intentText = (i) =>
  i.attack
    ? t({ es: "Va a atacar: ", en: "Will attack: " }) + i.attack + (i.hits ? `×${i.hits}` : "")
    : t({ es: "Va a defenderse: ", en: "Will defend: " }) + i.block;
// #endregion

// #region shuffle
// Fisher–Yates: walk the array from the end and swap each position with a
// random one at or before it. Every order is equally likely, which naive
// tricks such as sort(() => Math.random() - 0.5) do not guarantee.
function shuffle(a) {
  for (let i = a.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [a[i], a[j]] = [a[j], a[i]];
  }
  return a;
}
// #endregion

scene("game", () => {
  const deck = STARTER.map((id) => ({ id, ...CARDS[id] })); // every card you own
  const player = { hp: MAX_HP, block: 0 };
  let enemy, fight = -1, turn = 0, energy = 0, phase = "player";
  let drawPile = [], hand = [], discard = [];

  // #region piles
  // Three piles: draw, hand and discard. When the draw pile runs out, the
  // discard pile is shuffled and becomes the new draw pile.
  function draw(n) {
    for (let i = 0; i < n && hand.length < HAND_LIMIT; i++) {
      if (drawPile.length === 0) {
        if (discard.length === 0) break;
        drawPile = shuffle(discard);
        discard = [];
      }
      hand.push(drawPile.pop());
    }
  }
  // #endregion

  // #region combat
  const hit = (target, amount) => {
    const absorbed = Math.min(target.block, amount); // block soaks damage first
    target.block -= absorbed;
    target.hp -= amount - absorbed;
  };
  function play(card) {
    if (phase !== "player" || card.cost > energy) return;
    energy -= card.cost;
    hand.splice(hand.indexOf(card), 1);
    discard.push(card);
    for (let h = 0; h < (card.hits ?? 1) && card.damage; h++) hit(enemy, card.damage);
    player.block += card.block ?? 0;
    energy += card.energy ?? 0;
    if (card.draw) draw(card.draw);
    if (enemy.hp <= 0) return win();
    renderHand();
  }
  // #endregion

  // #region turns
  // Your turn: block resets, energy refills, you draw 5. When you end it,
  // the unplayed cards go to discard and the enemy carries out its intent.
  function startTurn() {
    phase = "player";
    player.block = 0;
    energy = ENERGY_PER_TURN;
    draw(HAND_SIZE);
    renderHand();
  }
  function endTurn() {
    if (phase !== "player") return;
    phase = "enemy";
    discard.push(...hand);
    hand = [];
    renderHand();
    wait(0.5, () => {
      const intent = enemy.pattern[turn % enemy.pattern.length];
      enemy.block = 0;
      if (intent.attack) for (let h = 0; h < (intent.hits ?? 1); h++) hit(player, intent.attack);
      enemy.block += intent.block ?? 0;
      shake(intent.attack ? 4 : 0);
      turn++;
      if (player.hp <= 0) return go("end", false, fight);
      wait(0.4, startTurn);
    });
  }
  function startFight() {
    fight++;
    enemy = { ...ENEMIES[fight], name: t(ENEMIES[fight].name), block: 0, maxHp: ENEMIES[fight].hp };
    turn = 0;
    drawPile = shuffle([...deck]); // every fight starts with the whole deck
    [hand, discard] = [[], []];
    startTurn();
  }
  // #endregion

  // #region rewards
  // After each victory: pick 1 of 3 random cards, or skip. Skipping is valid,
  // because every weak card you add makes your best cards come up less often.
  function win() {
    phase = "reward";
    hand = [];
    renderHand();
    if (fight === ENEMIES.length - 1) return wait(0.5, () => go("end", true, fight + 1, deck.length));
    const offer = shuffle([...REWARD_POOL]).slice(0, 3);
    const layer = add([rect(width(), height()), color(0, 0, 0), opacity(0.75), z(50), "reward"]);
    layer.add([text(t({ es: `¡Venciste a ${enemy.name}! Elige una carta para tu mazo`, en: `You beat ${enemy.name}! Pick a card for your deck` }), { size: 20, font: FONT }), pos(320, 60), anchor("center")]);
    offer.forEach((id, i) => cardView({ id, ...CARDS[id] }, 130 + i * 130, 100, 110, 140, layer, pick));
    const skip = layer.add([rect(140, 34, { radius: 6 }), pos(250, 280), color(70, 70, 90), area()]);
    skip.add([text(t({ es: "Saltar", en: "Skip" }), { size: 16, font: FONT }), pos(70, 17), anchor("center")]);
    skip.onClick(() => pick(null));
    function pick(card) {
      if (card) deck.push(card);
      destroyAll("reward");
      player.hp = Math.min(MAX_HP, player.hp + HEAL_BETWEEN_FIGHTS);
      startFight();
    }
  }
  // #endregion

  // #region interface
  // The hand is rebuilt every time it changes; the texts update every frame.
  add([rect(50, 70, { radius: 6 }), pos(110, 130), anchor("center"), color(90, 160, 240)]);
  const enemyBody = add([rect(70, 80, { radius: 6 }), pos(480, 125), anchor("center"), color(210, 80, 90)]);
  const playerInfo = add([text("", { size: 14, font: FONT, align: "center" }), pos(110, 188), anchor("center")]);
  const enemyInfo = add([text("", { size: 14, font: FONT, align: "center" }), pos(480, 188), anchor("center")]);
  const intentInfo = add([text("", { size: 15, font: FONT }), pos(480, 55), anchor("center"), color(255, 200, 120)]);
  const energyInfo = add([text("", { size: 16, font: FONT }), pos(110, 40), anchor("center"), color(255, 230, 120)]);
  const piles = add([text("", { size: 13, font: FONT }), pos(10, 228), color(190, 190, 220)]);
  const endButton = add([rect(120, 32, { radius: 6 }), pos(510, 212), color(70, 110, 80), area()]);
  endButton.add([text(t({ es: "Terminar turno", en: "End turn" }), { size: 15, font: FONT }), pos(60, 16), anchor("center")]);
  endButton.onClick(endTurn);
  onButtonPress("endTurn", endTurn);
  onUpdate(() => {
    playerInfo.text = `${t({ es: "Tú", en: "You" })}\n${t({ es: "Vida", en: "HP" })} ${player.hp}/${MAX_HP}` + (player.block ? ` · ${t({ es: "Bloqueo", en: "Block" })} ${player.block}` : "");
    enemyInfo.text = `${enemy.name}\n${t({ es: "Vida", en: "HP" })} ${Math.max(0, enemy.hp)}/${enemy.maxHp}` + (enemy.block ? ` · ${t({ es: "Bloqueo", en: "Block" })} ${enemy.block}` : "");
    intentInfo.text = phase === "player" ? intentText(enemy.pattern[turn % enemy.pattern.length]) : "";
    energyInfo.text = `${t({ es: "Energía", en: "Energy" })}: ${energy}/${ENERGY_PER_TURN}`;
    piles.text = `${t({ es: "Robo", en: "Draw" })}: ${drawPile.length}   ${t({ es: "Descarte", en: "Discard" })}: ${discard.length}   ${t({ es: "Mazo", en: "Deck" })}: ${deck.length}   ${t({ es: "Combate", en: "Fight" })} ${fight + 1}/${ENEMIES.length}`;
    enemyBody.opacity = enemy.hp > 0 ? 1 : 0.3;
    endButton.opacity = phase === "player" ? 1 : 0.4;
  });
  function cardView(card, x, y, w, h, parent, onPick) {
    const usable = phase === "reward" || card.cost <= energy;
    const c = parent.add([rect(w, h, { radius: 6 }), pos(x, y), color(card.damage ? rgb(120, 60, 60) : rgb(60, 80, 120)), outline(2, usable ? rgb(240, 230, 200) : rgb(80, 80, 90)), opacity(usable ? 1 : 0.55), area(), "card"]);
    c.add([circle(10), pos(12, 12), anchor("center"), color(255, 220, 90)]);
    c.add([text(String(card.cost), { size: 13, font: FONT }), pos(12, 12), anchor("center"), color(30, 30, 30)]);
    c.add([text(t(card.name), { size: 12, font: FONT, width: w - 8, align: "center" }), pos(w / 2, 34), anchor("center")]);
    c.add([text(describe(card), { size: 12, font: FONT, width: w - 8, align: "center" }), pos(w / 2, h * 0.68), anchor("center"), color(230, 230, 240)]);
    c.onClick(() => onPick(card));
  }
  function renderHand() {
    destroyAll("card");
    const w = Math.min(82, Math.floor(460 / Math.max(1, hand.length)) - 4);
    const x0 = 320 - (hand.length * (w + 4)) / 2;
    hand.forEach((card, i) => cardView(card, x0 + i * (w + 4), 250, w, 104, getTreeRoot(), play));
  }
  // #endregion
  startFight();
});

// #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("start", next);
  onClick(next);
}

scene("title", () =>
  screen(
    t({ es: "Construcción de mazos", en: "Deckbuilder" }),
    t({
      es: "Toca una carta para jugarla: cuesta energía (el número amarillo). Mira qué va a hacer el enemigo y decide si atacar o protegerte.\nVence a 3 enemigos; tras cada victoria eliges una carta nueva.\nHaz clic o pulsa espacio para empezar.",
      en: "Tap a card to play it: it costs energy (the yellow number). Check what the enemy is about to do and decide whether to attack or protect yourself.\nBeat 3 enemies; after each victory you pick a new card.\nClick or press space to start.",
    }),
    () => go("game"),
  ),
);

scene("end", (won, fights, deckSize) =>
  screen(
    won ? t({ es: "¡Venciste a los tres!", en: "You beat all three!" }) : t({ es: "Has caído", en: "You fell" }),
    (won ? t({ es: `Mazo final: ${deckSize} cartas.`, en: `Final deck: ${deckSize} cards.` }) : t({ es: `Caíste en el combate ${fights + 1}.`, en: `You fell in fight ${fights + 1}.` })) +
      t({ es: " Haz clic para repetir.", en: " Click to play again." }),
    () => go("game"),
  ),
);

go("title");
// #endregion

Design challenges

  • Designing cards that combine with each other without one combination breaking the game.
  • Making rewards real decisions. If taking a card is always right there is no decision, and every card added also dilutes the deck.
  • Showing the information that makes randomness manageable, such as the enemy's intent and the size of the piles, so losing does not feel unfair.
  • Shuffling without bias (Fisher–Yates) and deciding how much randomness there is through hand size, extra draws and cards that manipulate the deck.
  • Making many different cards readable at a glance, with short texts, icons and consistent keywords.

History

  1. 2008
    OriginDominion

    Donald X. Vaccarino's Dominion (2008) is a tabletop card game that set the genre's template and later got digital versions. Wikipedia mentions StarCraft: The Board Game (2007) as a precedent.

    Dominion (card game) (Wikipedia)
  2. 2013
    Dominion

    Dominion reaches the browser with an official version by Goko, in which the base game is free. Since 2017 the online version has been run by Shuffle iT.

    Dominion (card game) (Wikipedia)
  3. 2014

    Dream Quest, a mobile game by Peter Whalen, is the first known example of deck-building with a roguelike structure. Others followed, and its author was hired by Blizzard to work on Hearthstone.

    Roguelike deck-building game (Wikipedia)
  4. 2015
    Hand of Fate

    Hand of Fate builds each dungeon from cards in your deck and resolves fights as third-person action. It shows that the deck can generate the adventure, not just the combat.

    Hand of Fate (Wikipedia)
  5. 2017
    Slay the Spire

    Slay the Spire enters early access (full release in 2019). Its per-turn energy, enemies that announce their next move and the choice of one card out of three after each fight popularise roguelike deckbuilders.

    Slay the Spire (Wikipedia)
  6. 2020
    Monster Train

    Monster Train adds a spatial dimension, because cards summon creatures onto the different floors of a train that must be defended.

    Monster Train (Wikipedia)
  7. 2021
    Inscryption

    Inscryption blends deck-building with horror, puzzles and a story, with cards that require sacrificing others to be played.

    Inscryption (Wikipedia)
  8. 2024
    Balatro

    Balatro, the work of a single developer, applies deck-building to a poker deck and to jokers that change the scoring rules.

    Balatro (Wikipedia)

Reference games

2015

Hand of Fate

Defiant Development. Windows, macOS, Linux, PlayStation 4, Xbox One

A mysterious dealer builds each dungeon from face-down cards taken from your deck and his. When a monster appears, the game switches to third-person action combat. After each dungeon you earn new cards to customise your deck.

2019

Slay the Spire

Mega Crit. Windows, macOS, Linux, Nintendo Switch, PlayStation 4, Xbox One, iOS, Android

You climb a tower of randomly generated floors, fighting with cards. Each turn you get three energy and a fresh hand, and enemies announce their next move. After each victory you pick one of three cards for your deck. It entered early access in 2017.

2020

Monster Train

Shiny Shoe. Windows, Xbox One, Nintendo Switch, iOS, PlayStation 5

You defend a four-floor train by playing cards that summon creatures or trigger effects. Invaders climb floor by floor towards the pyre at the top, so besides building your deck you decide which floor each creature goes on.

2021

Inscryption

Daniel Mullins Games. Windows, macOS, Linux, PlayStation 4, PlayStation 5, Nintendo Switch, Xbox One, Xbox Series X/S

You play cards in a dark cabin against a mysterious opponent. Between matches you add or remove cards from your deck, many of which require sacrificing others to come into play, and you can leave the table to solve the cabin's puzzles.

2024

Balatro

LocalThunk. Windows, macOS, PlayStation 4, PlayStation 5, Xbox One, Xbox Series X/S, Nintendo Switch, Android, iOS

You play poker hands from a 52-card deck to beat ever higher scores, with limited hands and discards. Between rounds you buy jokers that change how scoring works and cards that modify your deck. It was made by a single developer.

Exercise

Play the demo twice, once always taking a card and once skipping every reward. Which deck went further, and why? Then design three new cards in the CARDS object, one of them meant to combine with another, and test them.

Sources