Card / Deckbuilder Game
Weeks 19-20 | Building and curating a system (your deck) that generates emergent strategy through the random order of card draws.
Prerequisites
| Module | What You Used From It |
|---|---|
| Module 01 - Pong | Basic game state management, input handling, and game loop fundamentals |
Note: This module is the most different from all others in the bootcamp. Every previous module has been real-time — continuous input, frame-by-frame physics, spatial collision. This module is turn-based and UI-heavy. The gameplay loop is: draw cards, evaluate options, play cards, resolve effects, end turn. The hard part is not the game logic — it is making the cards feel good to play through animation and UI.
Week 1: History & Design Theory
The Origin
Magic: The Gathering (Richard Garfield, Wizards of the Coast, 1993) invented the trading card game. Before Magic, games came in a box and every player had the same pieces. Garfield's breakthrough was that the game existed between the matches: you built your deck from a personal collection, choosing which cards to include based on strategy, synergy, and resource constraints. The mana system created a resource economy that forced tradeoffs every turn. A deck was not just a collection of powerful cards — it was a system with its own internal logic, card ratios, and win conditions. Magic proved that the metagame of construction could be as deep as the game itself.
How the Genre Evolved
Hearthstone (Blizzard, 2014) proved that card games could thrive as digital-first experiences. Where Magic was designed for physical cards and adapted to digital, Hearthstone was built for screens from the start. It simplified Magic's complexity — no instant-speed responses during the opponent's turn, automatic mana growth each turn, a maximum of 7 minions on the board — making it accessible to players who would never walk into a game store. Crucially, Hearthstone demonstrated that digital cards could do things physical cards cannot: generate random outcomes, transform into other cards, create cards that never existed in the collection.
Slay the Spire (MegaCrit, 2019) created a new genre by merging deckbuilding with the roguelike structure. Instead of constructing a deck before the game, you built your deck as you played — starting with a weak set of basic cards and adding new ones as rewards after each combat encounter. Slay the Spire's key insight was that smaller decks are often better than larger ones — adding a mediocre card dilutes the probability of drawing your powerful cards. This turned deckbuilding into an editing problem: what you leave out matters as much as what you include.
Balatro (LocalThunk, 2024) exploded the assumption that deckbuilders need fantasy combat. Built around poker hands with modifier cards (Jokers) that transform scoring rules, Balatro proved that the deckbuilder structure works with any card system. Inscryption (Daniel Mullins, 2021) pushed in a different direction, wrapping a deckbuilder inside a horror narrative that broke the fourth wall. Together, these games show that the deckbuilder format is a container — a structure for emergent system-building that can hold any content.
What Makes Deckbuilders Great
A great deckbuilder makes you feel like an engineer. Your deck is a machine you are designing under constraints: limited resources to play cards, limited slots in your hand, limited opportunities to add new cards. The randomness of the draw order means your machine never runs exactly the same way twice, so you must build for resilience rather than a single perfect sequence. The best deckbuilders create moments where cards you added for different reasons accidentally combine into something powerful — emergent behavior from simple rules.
The Essential Mechanic
Building and curating a system (your deck) that generates emergent strategy through the random order of card draws.
Week 2: Build the MVP
What You're Building
A single-player deckbuilder in the style of Slay the Spire: the player fights a series of enemies using a deck of cards, gaining new cards after each victory. Combat is turn-based with an energy system, and enemies telegraph their next action so the player can plan strategically.
Core Concepts (Must Implement)
1. Deck, Hand, and Discard as Data Structures
The core data model of a deckbuilder is three linked collections: the draw pile (a shuffled stack), the hand (an ordered array the player can see and interact with), and the discard pile (a stack of used cards). Drawing a card pops from the draw pile and pushes to the hand. Playing a card removes it from the hand and pushes to the discard pile. When the draw pile is empty, shuffle the discard pile and it becomes the new draw pile.
class DeckState:
drawPile = [] // Stack
hand = [] // Array
discardPile = [] // Stack
function shuffle(pile):
// Fisher-Yates shuffle
for i from pile.length - 1 down to 1:
j = randomInt(0, i)
swap(pile[i], pile[j])
function drawCard(state, count):
for i in range(count):
if state.drawPile.isEmpty():
if state.discardPile.isEmpty():
return
state.drawPile = state.discardPile
state.discardPile = []
shuffle(state.drawPile)
card = state.drawPile.pop()
state.hand.append(card)
function playCard(state, handIndex):
card = state.hand.removeAt(handIndex)
state.discardPile.push(card)
return card Why it matters: This is a circular buffer system with three stages. The Fisher-Yates shuffle is the only correct O(n) shuffling algorithm. Understanding this cycle is essential because every card you add changes the probability of drawing every other card.
Click "Draw" to draw cards from the deck to your hand. Click a card in your hand to play it (sends to discard). Click "Shuffle" to return the discard pile to the deck. Counts update live.
2. Card Effect System Using the Command Pattern
Each card is a command object with an execute(gameState) method. A "Strike" card executes dealDamage(enemy, 6). A "Defend" card executes gainBlock(player, 5). A "Draw Two" card executes drawCard(state, 2). This means cards are data, not code branches.
// Card defined as a command object
class Card:
name = "Strike"
cost = 1
description = "Deal 6 damage."
target = "single_enemy"
function execute(gameState, target):
dealDamage(gameState, target, 6)
// Playing a card
function playSelectedCard(gameState, handIndex, target):
card = gameState.deck.hand[handIndex]
if gameState.energy >= card.cost:
gameState.energy -= card.cost
card.execute(gameState, target)
gameState.deck.discardPile.push(
gameState.deck.hand.removeAt(handIndex)
) Why it matters: This is the Command pattern. Each card encapsulates an action and its parameters in a self-contained object. Adding a new card means adding a new class, not modifying a switch statement.
You have 3 energy per turn and 4 cards with different effects. Click a card to play it against the enemy. Click "End Turn" to let the enemy act and start a new turn. Defeat the enemy to win!
3. Turn Structure and Phase System
Each turn follows a strict sequence of phases: Draw Phase -> Action Phase -> Enemy Action Phase -> Discard Phase. This is a state machine.
enum TurnPhase:
DRAW, ACTION, ENEMY_ACTION, DISCARD, CHECK_WIN_LOSS
function advancePhase(gameState):
switch gameState.phase:
case DRAW:
gameState.energy = MAX_ENERGY
drawCard(gameState.deck, CARDS_PER_DRAW)
gameState.phase = ACTION
case ACTION:
gameState.phase = ENEMY_ACTION
case ENEMY_ACTION:
for enemy in gameState.enemies:
executeIntent(enemy, gameState)
chooseNextIntent(enemy)
gameState.phase = DISCARD
case DISCARD:
discardHand(gameState.deck)
gameState.phase = CHECK_WIN_LOSS
case CHECK_WIN_LOSS:
if gameState.player.hp <= 0:
triggerGameOver()
else if allEnemiesDead(gameState):
triggerVictory()
else:
gameState.phase = DRAW
advancePhase(gameState) Why it matters: This is a finite state machine with strict phase ordering. The key insight is that the Action phase is the only phase that waits for external input; all other phases are automatic transitions.
4. Energy/Mana Resource System
The player has a limited amount of energy each turn (typically 3). Each card has a cost. Playing a card deducts its cost. Energy resets at the start of each turn.
gameState.maxEnergy = 3
gameState.energy = gameState.maxEnergy
function canPlayCard(gameState, card):
return gameState.energy >= card.cost Why it matters: This is a resource budgeting system. The strategic depth comes from scarcity: if every card cost 0, there would be no decisions. Constraints create gameplay.
5. Enemy Intent / Telegraph System
Enemies declare what they will do next turn, and this information is visible to the player. The player uses this information to decide: do I play defensive cards to absorb the incoming damage, or go all-in on attack?
function chooseNextIntent(enemy):
roll = random()
if roll < 0.6:
enemy.intent = { type: "attack", value: 12, icon: "sword" }
else if roll < 0.85:
enemy.intent = { type: "defend", value: 8, icon: "shield" }
else:
enemy.intent = { type: "buff", value: 3, effect: "strength", icon: "flame" }
function executeIntent(enemy, gameState):
switch enemy.intent.type:
case "attack":
damage = calculateDamage(enemy.intent.value, enemy, gameState.player)
applyDamage(gameState.player, damage)
case "defend":
enemy.block += enemy.intent.value
case "buff":
addStatusEffect(enemy, enemy.intent.effect, enemy.intent.value) Why it matters: This is information design — deliberately exposing internal state to an external actor so they can make informed decisions. The intent system transforms combat from a guessing game into a planning puzzle.
6. UI-Driven Gameplay
This is the first module where the UI is the gameplay, not just a HUD overlay. The player interacts with cards visually: cards fan out in the hand, hovering over a card shows its details, clicking plays it.
function layoutHand(hand, screenWidth, handY):
cardWidth = 120
maxSpread = screenWidth * 0.6
totalWidth = min(hand.length * cardWidth, maxSpread)
spacing = totalWidth / max(hand.length - 1, 1)
startX = (screenWidth - totalWidth) / 2
for i, card in enumerate(hand):
card.x = startX + i * spacing
card.y = handY
centerIndex = (hand.length - 1) / 2
card.rotation = (i - centerIndex) * FAN_ANGLE_PER_CARD Why it matters: In a deckbuilder, the player interacts through UI components — clickable cards, hover states, targeting cursors. State management, event handlers, and z-index stacking are all critical.
7. Status Effects and Modifier Stacking
Status effects like Poison, Strength, Vulnerability, and Block are stateful modifiers attached to an entity. When damage is dealt, the calculation passes through a chain of modifiers: base damage -> modified by Strength -> modified by Vulnerability -> reduced by Block.
// Damage calculation pipeline
function calculateDamage(baseDamage, attacker, target):
damage = baseDamage
if "strength" in attacker.statusEffects:
damage += attacker.statusEffects["strength"].value
if "vulnerable" in target.statusEffects:
damage = floor(damage * 1.5)
return max(damage, 0)
function applyDamage(target, damage):
if target.block > 0:
absorbed = min(target.block, damage)
target.block -= absorbed
damage -= absorbed
target.hp -= damage Why it matters: This is a modifier pipeline. Each modifier is independent and composable. The stacking rules are a state accumulation problem: applying the same effect multiple times must have well-defined behavior.
Add or remove Strength (increases outgoing damage) and Vulnerable (increases incoming damage by 50%). Watch the damage calculation pipeline update in real-time as modifiers stack.
8. Reward and Deck Growth
After winning a combat, the player is offered a choice of 3 new cards to add to their deck (or skip). The critical design insight is that adding a card is not always good. Every card you add dilutes your draw probability for every other card.
function generateRewardCards(playerClass, combatDifficulty):
cardPool = getCardsForClass(playerClass)
weights = adjustWeightsByDifficulty(BASE_WEIGHTS, combatDifficulty)
rewards = []
for i in range(3):
rarity = weightedRandomChoice(["common", "uncommon", "rare"], weights)
card = randomChoice(cardPool.filter(c => c.rarity == rarity))
rewards.append(card)
return rewards Why it matters: Adding a card changes the statistical distribution of every future draw. The "skip" option is the most interesting design element — sometimes the best decision is to not add something.
Stretch Goals
- Card upgrades: Allow upgrading a card at rest sites (e.g., "Strike" becomes "Strike+" dealing 9 instead of 6).
- Relics / passive items: Persistent items that modify rules globally.
- Card removal: Allow removing a card from the deck at a shop or event.
- Multiple enemy encounters: Fights with 2-3 enemies simultaneously.
MVP Spec
| Element | Requirement |
|---|---|
| Deck | Starting deck of 10 cards with draw pile, hand, and discard pile |
| Card types | At least 5 distinct cards: attack, defend, card draw, buff, and debuff |
| Command pattern | Cards execute effects through a shared interface |
| Turn structure | Draw -> Action -> Enemy Action -> Discard phases as a state machine |
| Energy | 3 energy per turn; cards cost 1-3 energy |
| Enemy intent | Enemies display their next action; at least 3 intent types |
| Enemies | At least 3 sequential combat encounters |
| UI | Cards displayed as a hand fan; click to play; hover to inspect |
| Status effects | At least 3 status effects with stacking |
| Damage pipeline | Damage modified by attacker buffs, target debuffs, and block |
| Rewards | After each combat, choose 1 of 3 cards to add (or skip) |
| Win/Lose | Player wins by defeating all encounters; loses if HP reaches 0 |
Deliverable
A playable single-player deckbuilder with at least 3 combat encounters, a working card effect system using the Command pattern, a turn-based phase state machine, an enemy intent system, and post-combat card rewards. Submit the project along with a brief writeup (3-5 sentences) explaining your card effect architecture and how you would extend it to support 50+ unique cards without modifying existing code.
Analogies by Background
These analogies map game dev concepts to patterns you already know. Find your background below.
For Backend Developers
| Concept | Analogy |
|---|---|
| Deck, Hand, and Discard | Message processing pipeline — draw pile is the message queue, hand is the processing buffer, discard pile is the completed queue |
| Card Effect System (Command Pattern) | Job queue workers — each job encapsulates its own execution logic; also database migrations with up/down methods |
| Turn Structure and Phase System | Order processing state machine (placed -> paid -> shipped -> delivered) or CI/CD pipeline |
| Energy/Mana Resource System | API rate limiting — N requests per time window |
| Enemy Intent / Telegraph System | Health check endpoints and observability dashboards — exposing internal state so operators can make informed decisions |
| UI-Driven Gameplay | Admin dashboard or form-heavy CRUD UI |
| Status Effects and Modifier Stacking | Express.js / Django middleware pipeline — each middleware transforms the request/response |
| Reward and Deck Growth | A/B test variant management — adding a new variant changes probabilities for all others |
For Frontend Developers
| Concept | Analogy |
|---|---|
| Deck, Hand, and Discard | State management with multiple collections — like a Redux store with queue, active, and archive slices |
| Card Effect System (Command Pattern) | React component render props or Vue slots — each card is a self-contained component |
| Turn Structure and Phase System | Multi-step wizard or checkout flow |
| Energy/Mana Resource System | Budget-constrained UI like a drag-and-drop dashboard with limited widget slots |
| Enemy Intent / Telegraph System | Tooltip previews and hover states — showing what will happen before the user commits |
| UI-Driven Gameplay | This IS frontend development — CSS transforms, hover states, z-index, and drag-and-drop |
| Status Effects and Modifier Stacking | CSS cascade and specificity — multiple style rules stacking on an element |
| Reward and Deck Growth | Feature toggles in a UI — adding a new toggle increases the combinatorial state space |
For Data / ML Engineers
| Concept | Analogy |
|---|---|
| Deck, Hand, and Discard | Sampling without replacement from a finite population |
| Card Effect System (Command Pattern) | Pluggable transform functions in a data pipeline |
| Turn Structure and Phase System | DAG-based pipeline orchestration (Airflow, Prefect) |
| Energy/Mana Resource System | Compute budget in optimization — limited function evaluations per iteration |
| Enemy Intent / Telegraph System | Observable state in a POMDP — partial observability |
| UI-Driven Gameplay | Interactive data visualization dashboards (Plotly, Streamlit) |
| Status Effects and Modifier Stacking | Feature transformation pipeline — base value passes through a chain of vectorized operations |
| Reward and Deck Growth | Adding features to a model — each new card changes the probability distribution of draws |
Discussion Questions
- The Command pattern makes cards data-driven. How would you implement a card that says "Deal damage equal to the number of cards in your discard pile"? What about "Copy the last card you played and play it again"? Where does the Command pattern break down?
- Smaller decks are often better than larger decks. This is counterintuitive — more options should be better, right? How does this map to software architecture? When has adding a feature, dependency, or service made your system worse overall?
- The damage calculation pipeline passes through multiple modifiers. What happens when you need to add a new modifier that interacts with existing ones? How do you manage ordering and priority?
- Enemy intent gives perfect information about the next turn but uncertainty about the turn after. How does this balance of known and unknown information create strategic depth?