Lesson 27
Card game mechanics: decks are state machines with personality.
Card games are powerful because a small set of rules creates many situations. The code needs clean piles, clear effects and a turn flow the player can read.
1. Represent cards as data
A card needs an id, name, cost, text and effect. The art can come later. Keep rules in data so cards can be rendered, balanced and tested without hunting through UI code. Even traditional games such as War, Hearts or Spades benefit from explicit card objects because suits, ranks and ownership become readable.
const CARD_LIBRARY = {
spark: { id: "spark", name: "Spark", cost: 1, text: "Deal 4 damage.", effect: "damage", value: 4 },
guard: { id: "guard", name: "Guard", cost: 1, text: "Gain 5 shield.", effect: "shield", value: 5 },
draw2: { id: "draw2", name: "Quick Study", cost: 0, text: "Draw 2 cards.", effect: "draw", value: 2 }
};
function createCard(id) {
return { ...CARD_LIBRARY[id], instanceId: crypto.randomUUID ? crypto.randomUUID() : id + Math.random() };
}
Source pattern: Supagames includes multiple card games and magic duel concepts where suit, rank, cost, card text and effect data drive play.
2. Use piles: deck, hand, discard
The most important card architecture is pile movement. Cards move from deck to hand, hand to discard, discard back to deck, or hand to play area. If you model piles clearly, many mechanics become simple.
const state = {
deck: [],
hand: [],
discard: [],
energy: 3
};
function drawCard() {
if (state.deck.length === 0) reshuffleDiscardIntoDeck();
const card = state.deck.pop();
if (card) state.hand.push(card);
}
function discardCard(card) {
state.discard.push(card);
}
Never duplicate a card in multiple piles unless the game intentionally copies it. Most card bugs are pile bugs: a card vanishes, appears twice or remains clickable after being played.
3. Shuffle with Fisher-Yates
Random sorting is a common shortcut, but Fisher-Yates is simple and reliable. It swaps each card with a random earlier card. If you need daily challenges, use a seeded random function so everyone gets the same deck order.
function shuffle(array, random = Math.random) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(random() * (i + 1));
const tmp = array[i];
array[i] = array[j];
array[j] = tmp;
}
return array;
}
function reshuffleDiscardIntoDeck() {
state.deck = shuffle(state.discard.splice(0));
}
For debugging, log the first few card ids after shuffle. It helps reproduce bad openings and balance problems.
4. Playing a card checks cost, target and effect
Card play should have a strict order. Check the card is in hand. Check cost. Check target if needed. Spend energy. Apply effect. Move the card to discard. Update UI. Skipping these steps creates exploits.
function playCard(cardId, target) {
const index = state.hand.findIndex(card => card.instanceId === cardId);
if (index < 0) return false;
const card = state.hand[index];
if (state.energy < card.cost) return false;
if (card.effect === "damage" && !target) return false;
state.energy -= card.cost;
applyCardEffect(card, target);
state.hand.splice(index, 1);
discardCard(card);
updateCardUI();
return true;
}
Returning true or false helps buttons and tests. The UI can show a denied action without corrupting state.
5. Effects should be small and composable
A card effect resolver can start simple. Add more effect types only when the design needs them. If cards become complex, split effect functions and allow cards to hold an array of effects.
function applyCardEffect(card, target) {
if (card.effect === "damage") {
target.hp -= card.value;
addCombatLog(card.name + " deals " + card.value + " damage.");
}
if (card.effect === "shield") {
player.shield += card.value;
addCombatLog("Gained " + card.value + " shield.");
}
if (card.effect === "draw") {
for (let i = 0; i < card.value; i++) drawCard();
}
}
Readable logs are important in card games because several effects may happen quickly. The player should understand why the board changed.
6. Balance starts with hand size and energy
Cards need constraints. Hand size controls options. Energy controls how many cards can be played. Deck size controls consistency. If the player can play every card every turn, the deck stops being a deck and becomes a menu.
function startTurn() {
state.energy = 3;
while (state.hand.length < 5) drawCard();
}
function endTurn() {
while (state.hand.length) {
discardCard(state.hand.pop());
}
enemyTurn();
}
Card games thrive on partial information and tradeoffs. Good cards create tempting choices; good rules prevent every choice from being available at once.
7. Make card intent visible
Cards are small UI objects, so every pixel must work. The player should see cost, name, effect, target type and disabled state without opening a manual. If a card cannot be played, show why: not enough energy, no target, silenced, deck locked or already used. A disabled card with no explanation feels broken.
For mobile, avoid forcing players to drag tiny cards across the screen. Tap to select, highlight valid targets, tap target to confirm and provide a cancel button. The same `playCard` function should run whether the input came from mouse, touch or keyboard. That keeps the card system deterministic and prevents mobile-only rule bugs.