Build a Terminal Deck-Building Card Battle
Build a playable deck-building card game in your terminal using Python and Textual.
Introduction
30 Second Summary
Every card game is a series of decisions under pressure. You have limited resources, incomplete information, and an opponent who is actively trying to beat you.
In this project, you will build a fully playable deck-building card battle game that runs entirely in your terminal. Using Python and the Textual framework, you will design interlocking card mechanics, implement enemy AI, and render a polished keyboard-driven interface.
What You'll Build
You will run a single command, and a full-screen card battle appears in your terminal where you play attack, defense, and power cards against an enemy that telegraphs its next move.
By the end of this project, you'll have:
- A playable card battle where you press number keys to play cards from your hand, spend energy strategically, and watch damage resolve with status effect interactions against a live enemy.
- An enemy AI intent system that telegraphs the enemy's next action every turn, forcing you to weigh offense against defense before committing your limited energy.
- A complete game engine architecture separating data logic from presentation, so you can test the game state independently and swap in new enemies or cards without touching the UI.
- Secret Mission: Add a card reward screen between two enemy encounters, letting you draft a new card into your deck before fighting a harder second boss.
Are there any prerequisites?
This project assumes intermediate Python knowledge. You should be comfortable with classes, inheritance, lists, dictionaries, and basic data structures before starting.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Build the Game Engine
Before touching any UI, you need a game engine that manages cards, decks, combat math, and enemy behavior.
This engine is pure Python with zero rendering logic. That separation means you can test it by running game.py directly and seeing printed output before you build the TUI.
In this step, get ready to:
- Define card data classes and create the starting deck of 10 cards.
- Implement the enemy AI intent system with a cycling attack pattern.
- Build the GameState class with draw, play, and end-turn methods.
Define the card data model
Every card game needs a data model that describes what a card is, what effects exist, and what stats the player and enemy have. You will define these as Python dataclasses so the game engine can manipulate them cleanly.
- Open game.py in VS Code and add the module docstring, imports, and core data types by pasting this code:
"""Game engine for Spire of Shadows - handles all game state and logic."""
from dataclasses import dataclass, field
from enum import Enum
from random import shuffle, sample
from copy import deepcopy
# Card type determines how the card behaves when played
class CardType(Enum):
ATTACK = "Attack"
SKILL = "Skill"
POWER = "Power"
# Each card has a name, energy cost, type, numeric value, and description
@dataclass
class Card:
name: str
cost: int
card_type: CardType
value: int
description: str
# Status effects have a name, remaining duration, and optional value
@dataclass
class StatusEffect:
name: str
duration: int
value: int = 0
What does this code do?
CardType is an enum with three values: Attack cards deal damage, Skill cards provide defense or utility, and Power cards grant permanent buffs.
Card holds everything about a single card. StatusEffect tracks buffs and debuffs like Vulnerable or Weak, counting down each turn.
- Below the StatusEffect class, add the Enemy dataclass that powers the intent system:
# Enemy tracks HP, block, strength, and a cycling list of intents
@dataclass
class Enemy:
name: str
max_hp: int
hp: int
block: int = 0
strength: int = 0
intent_pattern: list = field(default_factory=list)
intent_index: int = 0
statuses: list = field(default_factory=list)
@property
def current_intent(self) -> dict:
return self.intent_pattern[self.intent_index % len(self.intent_pattern)]
def advance_intent(self):
self.intent_index += 1
What is the intent system?
In Slay the Spire, enemies telegraph their next move so you can plan your turn. The intent_pattern list defines a repeating sequence of actions. The current_intent property uses modulo to cycle through them infinitely.
- Below the Enemy class, add the Player dataclass and the function that builds the starting deck:
# Player starts with 50 HP, 3 energy per turn, and no buffs
@dataclass
class Player:
max_hp: int = 50
hp: int = 50
energy: int = 3
max_energy: int = 3
block: int = 0
strength: int = 0
statuses: list = field(default_factory=list)
# Build a 10-card starting deck: 4 Strikes, 4 Defends, 1 Heavy Blow, 1 Dark Pact
def create_starting_deck() -> list[Card]:
deck = []
for _ in range(4):
deck.append(Card("Strike", 1, CardType.ATTACK, 6, "Deal 6 damage."))
for _ in range(4):
deck.append(Card("Defend", 1, CardType.SKILL, 5, "Gain 5 Block."))
deck.append(Card("Heavy Blow", 2, CardType.ATTACK, 14, "Deal 14 damage."))
deck.append(Card("Dark Pact", 1, CardType.POWER, 2, "Gain 2 Strength."))
return deck
What does this code do?
The Player has 3 energy per turn to spend on cards. Block absorbs incoming damage and resets each turn. Strength adds flat damage to all attacks.
create_starting_deck() builds a balanced 10-card deck. Strikes and Defends are cheap at 1 energy. Heavy Blow costs 2 but hits hard. Dark Pact grants permanent Strength that scales across the whole fight.
- Save game.py.
- Verify the data model loads correctly by running this in your terminal:
python -c "from game import create_starting_deck; print(len(create_starting_deck()))"
You should see 10 printed, confirming the deck builds correctly.
Seeing an import error?
Make sure you saved game.py and that your terminal is in the same directory as the file. Run ls to confirm you see game.py listed.
Help me debug my game.py import error
Implement the enemy AI intent system
The Enemy dataclass defines HOW intents work, but you still need to define WHICH enemies exist and what their patterns look like. Each enemy has a unique sequence of attacks, defenses, and buffs that repeats.
- Below create_starting_deck(), add the enemy factory functions that define each enemy's intent pattern:
# First enemy: a slime with a 4-action repeating pattern
def create_enemy_slime() -> Enemy:
return Enemy(
name="Corrupt Slime",
max_hp=30,
hp=30,
intent_pattern=[
{"type": "attack", "value": 8, "label": "Attack 8"},
{"type": "attack", "value": 12, "label": "Attack 12"},
{"type": "defend", "value": 6, "label": "Defend 6"},
{"type": "buff", "value": 2, "label": "Buff +2 Str"},
],
)
# Second enemy: a tougher knight with higher stats
def create_enemy_knight() -> Enemy:
return Enemy(
name="Shadow Knight",
max_hp=45,
hp=45,
intent_pattern=[
{"type": "attack", "value": 10, "label": "Attack 10"},
{"type": "attack", "value": 10, "label": "Attack 10"},
{"type": "defend", "value": 8, "label": "Defend 8"},
{"type": "buff", "value": 3, "label": "Buff +3 Str"},
],
)
How does the intent pattern create strategy?
Because the player always sees the enemy's NEXT action, they can plan. If the slime is about to Attack 12, you might play Defend. If it's about to Buff, you have a safe turn to go all-in on damage.
The Shadow Knight is harder because it attacks twice in a row before defending. Two consecutive Attack 10s punish players who skip defense entirely.
- Below the enemy factories, add a pool of uncommon cards that will be used later for card rewards:
# Card pool for reward screens (used in Secret Mission)
UNCOMMON_CARDS = [
Card("Cleave", 1, CardType.ATTACK, 4, "Deal 4 damage. Apply Vulnerable."),
Card("Fortify", 1, CardType.SKILL, 8, "Gain 8 Block. Gain 1 Strength."),
Card("Siphon", 2, CardType.ATTACK, 5, "Deal 5 damage. Heal 3 HP."),
]
- Save game.py.
- Verify the enemy intent system works by running:
python -c "from game import create_enemy_slime; e = create_enemy_slime(); print(e.current_intent['label'])"
You should see Attack 8 printed. That is the first action in the slime's cycling pattern.
Seeing a KeyError or AttributeError?
Check that your intent_pattern list uses the exact dictionary keys: "type", "value", and "label". Make sure the current_intent property has the @property decorator above it.
Help me fix my enemy intent system
Build the GameState class
The GameState class ties everything together. It manages the draw pile, hand, discard pile, energy, turn flow, and combat resolution. This is the largest piece of the engine.
- Below UNCOMMON_CARDS, start the GameState class with its constructor and combat initialization:
class GameState:
def __init__(self, deck: list[Card] = None, enemy: Enemy = None):
self.player = Player()
self.deck = deck if deck else create_starting_deck()
self.draw_pile: list[Card] = []
self.hand: list[Card] = []
self.discard_pile: list[Card] = []
self.enemy = enemy if enemy else create_enemy_slime()
self.log: list[str] = []
self.game_over = False
self.victory = False
self.turn = 1
self._init_combat()
def _init_combat(self):
# Deep copy the deck so originals stay clean between fights
self.draw_pile = deepcopy(self.deck)
shuffle(self.draw_pile)
self.hand = []
self.discard_pile = []
self.player.energy = self.player.max_energy
self.player.block = 0
self.enemy.block = 0
self.draw_hand()
What does this code do?
The constructor sets up a fresh game with a player, a shuffled deck, and an enemy. _init_combat() uses deepcopy to clone the deck into the draw pile. This prevents mutations to drawn cards from corrupting the original deck if the player replays.
- Add the draw, validation, and play methods inside the GameState class:
def draw_hand(self, count: int = 5):
# Draw cards, reshuffling discard into draw if empty
for _ in range(count):
if not self.draw_pile:
self.draw_pile = self.discard_pile[:]
self.discard_pile = []
shuffle(self.draw_pile)
if not self.draw_pile:
break
self.hand.append(self.draw_pile.pop())
def can_play(self, index: int) -> bool:
if index < 0 or index >= len(self.hand):
return False
return self.hand[index].cost <= self.player.energy
def play_card(self, index: int) -> str:
if not self.can_play(index):
return "Cannot play that card."
card = self.hand.pop(index)
self.player.energy -= card.cost
message = self._resolve_card(card)
# Powers are exhausted (not discarded)
if card.card_type != CardType.POWER:
self.discard_pile.append(card)
self._check_enemy_dead()
return message
What does this code do?
draw_hand() draws 5 cards. When the draw pile is empty, it reshuffles the discard pile back in. This is the core deck-cycling mechanic from Slay the Spire.
play_card() deducts energy, resolves the card's effect, then discards it. Power cards are special: they get exhausted (removed from the game) instead of going to the discard pile.
- Save game.py and verify the draw system works by running:
python -c "from game import GameState; g = GameState(); print(f'Hand: {len(g.hand)} cards, Draw pile: {len(g.draw_pile)}')"
You should see Hand: 5 cards, Draw pile: 5. Five cards drawn from a 10-card deck leaves 5 in the pile.
- Next, add the card resolution method that determines what each card does when played:
def _resolve_card(self, card: Card) -> str:
if card.name == "Strike":
damage = self._calculate_damage(card.value, self.player, self.enemy)
self._deal_damage_to_enemy(damage)
return f"Strike deals {damage} damage!"
elif card.name == "Defend":
self.player.block += card.value
return f"Gain {card.value} Block."
elif card.name == "Heavy Blow":
damage = self._calculate_damage(card.value, self.player, self.enemy)
self._deal_damage_to_enemy(damage)
return f"Heavy Blow deals {damage} damage!"
elif card.name == "Dark Pact":
self.player.strength += card.value
return f"Dark Pact grants +{card.value} Strength!"
elif card.name == "Cleave":
damage = self._calculate_damage(card.value, self.player, self.enemy)
self._deal_damage_to_enemy(damage)
self._apply_vulnerable(self.enemy, 2)
return f"Cleave deals {damage} damage and applies Vulnerable!"
elif card.name == "Fortify":
self.player.block += card.value
self.player.strength += 1
return f"Fortify grants {card.value} Block and +1 Strength!"
elif card.name == "Siphon":
damage = self._calculate_damage(card.value, self.player, self.enemy)
self._deal_damage_to_enemy(damage)
heal = min(3, self.player.max_hp - self.player.hp)
self.player.hp += heal
return f"Siphon deals {damage} damage and heals {heal} HP!"
return f"Played {card.name}."
What does this code do?
Each card name maps to a specific effect. Attack cards route through _calculate_damage() which factors in Strength, Weak, and Vulnerable. Skill cards add Block directly. Power cards grant permanent Strength.
The uncommon cards (Cleave, Fortify, Siphon) have compound effects. Cleave deals damage AND applies Vulnerable. Siphon deals damage AND heals. These create interesting decisions during card drafting later.
- Add the damage calculation and delivery helpers:
def _calculate_damage(self, base: int, attacker, target) -> int:
# Add attacker's strength, apply Weak/Vulnerable multipliers
damage = base + getattr(attacker, "strength", 0)
if self._has_status(attacker, "Weak"):
damage = int(damage * 0.75)
if self._has_status(target, "Vulnerable"):
damage = int(damage * 1.5)
return max(0, damage)
def _deal_damage_to_enemy(self, damage: int):
# Block absorbs damage first, remainder hits HP
if self.enemy.block > 0:
if damage <= self.enemy.block:
self.enemy.block -= damage
return
else:
damage -= self.enemy.block
self.enemy.block = 0
self.enemy.hp = max(0, self.enemy.hp - damage)
def _deal_damage_to_player(self, damage: int):
# Same block logic for player
if self.player.block > 0:
if damage <= self.player.block:
self.player.block -= damage
return
else:
damage -= self.player.block
self.player.block = 0
self.player.hp = max(0, self.player.hp - damage)
How does damage resolution work?
Damage flows through a pipeline: base value + Strength bonus, then multiplied by 0.75 if attacker is Weak, then multiplied by 1.5 if target is Vulnerable. Block absorbs damage before HP takes any hit.
- Add the status effect helpers and the enemy death check:
def _apply_vulnerable(self, target, duration: int):
# Stack duration if Vulnerable already exists
for s in target.statuses:
if s.name == "Vulnerable":
s.duration += duration
return
target.statuses.append(StatusEffect("Vulnerable", duration))
def _has_status(self, entity, name: str) -> bool:
return any(s.name == name and s.duration > 0 for s in entity.statuses)
def _tick_statuses(self, entity):
# Remove expired statuses, decrement remaining ones
entity.statuses = [s for s in entity.statuses if s.duration > 0]
for s in entity.statuses:
s.duration -= 1
def _check_enemy_dead(self):
if self.enemy.hp <= 0:
self.game_over = True
self.victory = True
What does this code do?
_apply_vulnerable() stacks duration if the status already exists. _tick_statuses() counts down each effect at end of turn, removing expired ones. This means Vulnerable applied for 2 turns wears off naturally.
- Add the end_turn method that resolves the enemy's action and prepares the next turn:
def end_turn(self) -> list[str]:
messages = []
# Discard remaining hand
self.discard_pile.extend(self.hand)
self.hand = []
# Resolve enemy intent
intent = self.enemy.current_intent
if intent["type"] == "attack":
damage = intent["value"] + self.enemy.strength
if self._has_status(self.enemy, "Weak"):
damage = int(damage * 0.75)
if self._has_status(self.player, "Vulnerable"):
damage = int(damage * 1.5)
self._deal_damage_to_player(damage)
messages.append(f"{self.enemy.name} attacks for {damage} damage!")
elif intent["type"] == "defend":
self.enemy.block += intent["value"]
messages.append(f"{self.enemy.name} gains {intent['value']} Block.")
elif intent["type"] == "buff":
self.enemy.strength += intent["value"]
messages.append(f"{self.enemy.name} gains +{intent['value']} Strength!")
self.enemy.advance_intent()
if self.player.hp <= 0:
self.game_over = True
self.victory = False
messages.append("You have been defeated!")
return messages
- Still inside the end_turn method, add the turn cleanup code directly below the return messages line:
# Turn cleanup: tick statuses, reset block, refill energy, draw
self._tick_statuses(self.player)
self._tick_statuses(self.enemy)
self.player.block = 0
self.enemy.block = 0
self.player.energy = self.player.max_energy
self.turn += 1
self.draw_hand()
messages.append(f"Turn {self.turn} begins. Drew {len(self.hand)} cards.")
return messages
What does end_turn do?
When the player ends their turn, all remaining hand cards go to the discard pile. The enemy then executes its telegraphed intent: dealing damage (reduced by player block), gaining block, or buffing its Strength.
After the enemy acts, the method ticks status effect durations down, resets block to 0 for both sides, refills player energy, and draws a fresh hand of 5 cards.
- Finally, below the GameState class (at the module level, no indentation), add a test block so you can run the file directly:
if __name__ == "__main__":
game = GameState()
print(f"Enemy: {game.enemy.name} (HP: {game.enemy.hp})")
print(f"Intent: {game.enemy.current_intent['label']}")
print(f"Hand ({len(game.hand)} cards):")
for i, card in enumerate(game.hand):
print(f" [{i+1}] {card.name} (Cost: {card.cost}) - {card.description}")
print(f"Draw pile: {len(game.draw_pile)} | Discard: {len(game.discard_pile)}")
What does this code do?
The if __name__ == "__main__" block only runs when you execute the file directly. It creates a new game and prints the enemy info, the player's starting hand, and pile counts. This is your quick test to confirm the entire engine works.
- Save game.py.
Before you run the test, what do you expect to see? The deck is shuffled randomly, so your hand will be different each time. But you should always see 5 cards in hand and the enemy's first intent.
- Run the game engine test by executing:
python game.py
You should see output showing the Corrupt Slime at 30 HP, its first intent (Attack 8), a hand of 5 cards with their names and costs, and the draw/discard pile counts (5 and 0).
Seeing a NameError or TypeError?
A NameError usually means a function or class is referenced before it's defined. Check that create_enemy_slime() and create_starting_deck() appear above the GameState class in your file.
A TypeError often means a missing self parameter on a method. Every method inside GameState must start with self as its first argument.
Help me debug my GameState class
✔️ Awesome, I've got everything!
Great. Double check you have saved game.py and that your output shows all five hand cards.
ⓧ I'd like to double check the full code
Your game.py is too long to show in one block here. Scroll up through the chunks you added and confirm, in order, that you have:
- The module docstring and all imports (dataclass, field, Enum, shuffle, sample, deepcopy).
- CardType, Card, StatusEffect, Enemy, and Player dataclasses.
- create_starting_deck(), create_enemy_slime(), create_enemy_knight(), and UNCOMMON_CARDS.
- The full GameState class with all methods: __init__, _init_combat, draw_hand, can_play, play_card, _resolve_card, _calculate_damage, _deal_damage_to_enemy, _deal_damage_to_player, _apply_vulnerable, _has_status, _tick_statuses, _check_enemy_dead, and end_turn.
- The if __name__ == "__main__" test block at the bottom.
Your game engine is fully functional. You have cards, a deck, an enemy with a telegraphed attack pattern, and all the combat math. Next up, you will give this engine a face by building the terminal UI with Textual.
Wire Up the TUI Shell
Your game engine can shuffle a deck, draw a hand, and track enemy intent. But right now, the only way to see any of it is a wall of print() output.
In this step, you will build the visual layer: a full-screen Textual TUI with styled panels for the enemy, your hand of cards, and player stats. The UI will render the game state, but you will not be able to interact with it yet.
In this step, get ready to:
- Build custom widgets for the enemy panel, stats panel, hand panel, and game log.
- Create the SpireApp class with a CSS-driven three-row layout.
- Wire the GameState into the app so all panels render the starting state on launch.
Start the TUI file and build the EnemyPanel widget
The enemy panel is the most complex widget. It renders an HP bar, the enemy's telegraphed intent, and any active status effects. You will build it in two pieces: the CSS styling, then the rendering logic.
- Open app.py in VS Code. It is currently empty.
- Paste the following imports and EnemyPanel class skeleton:
"""TUI layer for Spire of Shadows - built with Textual."""
from textual.app import App, ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.reactive import reactive
from textual.widget import Widget
from textual.widgets import Static, Header, Footer, Label
from textual import events
from game import GameState, CardType, create_starting_deck, create_enemy_slime
class EnemyPanel(Static):
DEFAULT_CSS = """
EnemyPanel {
width: 100%;
height: auto;
padding: 1 2;
background: $surface;
border: heavy $error;
margin-bottom: 1;
}
"""
What does this code do?
The imports pull in Textual's building blocks: App for the application shell, Static for text-rendering widgets, Container/Horizontal for layout, and Header/Footer for the chrome.
EnemyPanel extends Static, which is a widget that displays Rich-formatted text. The DEFAULT_CSS string gives it padding, a red border, and auto height so it grows to fit its content.
- Now add the rendering method inside the EnemyPanel class, directly below the DEFAULT_CSS:
def render_enemy(self, game: GameState):
enemy = game.enemy
# Build a visual HP bar using filled/empty block characters
hp_bar_width = 20
hp_ratio = enemy.hp / enemy.max_hp
filled = int(hp_ratio * hp_bar_width)
empty = hp_bar_width - filled
hp_bar = "[red]" + "█" * filled + "[/red]" + "[dim]░[/dim]" * empty
# Show active status effects if any exist
statuses = ""
if enemy.statuses:
status_parts = [f"{s.name}({s.duration})" for s in enemy.statuses if s.duration > 0]
statuses = " ".join(status_parts)
block_text = f" Block: {enemy.block}" if enemy.block > 0 else ""
str_text = f" Str: {enemy.strength}" if enemy.strength > 0 else ""
# The intent tells the player what the enemy will do next
intent = enemy.current_intent["label"]
content = (
f"[bold]{enemy.name}[/bold]\n"
f"HP: {enemy.hp}/{enemy.max_hp} {hp_bar}{block_text}{str_text}\n"
f"Intent: [yellow]{intent}[/yellow]\n"
f"{statuses}"
)
self.update(content)
What does render_enemy do?
It reads the current GameState and builds a Rich-markup string showing the enemy name, a visual HP bar (red filled blocks for remaining HP, dim blocks for lost HP), and the telegraphed intent in yellow.
The self.update(content) call pushes the new text into the Static widget, and Textual redraws it on screen.
- Save app.py.
- Confirm the widget loads without errors by running:
python -c "from app import EnemyPanel; print('EnemyPanel loaded')"
You should see EnemyPanel loaded printed with no tracebacks.
Seeing an ImportError or SyntaxError?
Check that the indentation of render_enemy is exactly one level inside the class (4 spaces). Also confirm game.py is in the same folder as app.py.
Help me debug my EnemyPanel import error.
Build the StatsPanel and HandPanel widgets
The stats panel shows your player's HP, energy, and block. The hand panel renders each card with its number key, type, cost, and description.
- Add the StatsPanel class below EnemyPanel in app.py:
class StatsPanel(Static):
DEFAULT_CSS = """
StatsPanel {
width: 100%;
height: auto;
padding: 1 2;
background: $surface;
border: tall $primary;
}
"""
def render_stats(self, game: GameState):
player = game.player
# Gather status effects for display
statuses = ""
if player.statuses:
status_parts = [f"{s.name}({s.duration})" for s in player.statuses if s.duration > 0]
statuses = "\n" + " ".join(status_parts)
str_text = f"\nStrength: {player.strength}" if player.strength > 0 else ""
content = (
f"[bold]Player[/bold]\n"
f"HP: {player.hp}/{player.max_hp}\n"
f"Energy: {'◆' * player.energy}{'◇' * (player.max_energy - player.energy)} ({player.energy}/{player.max_energy})\n"
f"Block: {player.block}\n"
f"Draw: {len(game.draw_pile)} | Discard: {len(game.discard_pile)}"
f"{str_text}{statuses}"
)
self.update(content)
What does render_stats do?
It reads the player object and builds a summary: HP, energy displayed as filled/empty diamond characters, current block value, and draw/discard pile sizes. Strength and status effects only appear when they are active.
- Add the HandPanel class below StatsPanel:
class HandPanel(Static):
DEFAULT_CSS = """
HandPanel {
width: 100%;
height: auto;
padding: 1 2;
background: $surface;
border: heavy $success;
}
"""
def render_hand(self, game: GameState):
if not game.hand:
self.update("[dim]No cards in hand.[/dim]")
return
# Build a numbered list of cards with type color coding
lines = ["[bold]Your Hand[/bold] (press 1-5 to play, E to end turn)\n"]
for i, card in enumerate(game.hand):
playable = "bold" if card.cost <= game.player.energy else "dim"
type_color = {"Attack": "red", "Skill": "blue", "Power": "magenta"}.get(card.card_type.value, "white")
lines.append(
f" [{playable}][{i+1}] {card.name} [{type_color}]({card.card_type.value})[/{type_color}] "
f"Cost: {card.cost} - {card.description}[/{playable}]"
)
self.update("\n".join(lines))
What does render_hand do?
It loops through the cards in the player's hand and formats each one with a number key label, colored type indicator (red for Attack, blue for Skill, magenta for Power), energy cost, and description. Cards the player cannot afford are dimmed.
- Save app.py and confirm all widgets load by running:
python -c "from app import EnemyPanel, StatsPanel, HandPanel; print('All panels loaded')"
You should see All panels loaded with no errors.
Seeing a NameError for GameState?
Make sure your from game import GameState, CardType, create_starting_deck, create_enemy_slime line is at the top of app.py. The widget methods reference GameState as a type hint.
Help me fix my widget import error.
Add the GameLog and create the SpireApp
The GameLog widget keeps a scrolling message history. The SpireApp class ties everything together with a CSS layout that places the enemy at the top, stats and log in the middle, and your hand at the bottom.
- Add the GameLog class below HandPanel in app.py:
class GameLog(Static):
DEFAULT_CSS = """
GameLog {
width: 100%;
height: 8;
padding: 0 2;
background: $boost;
overflow-y: auto;
}
"""
def __init__(self):
super().__init__()
self.messages: list[str] = []
def add_message(self, msg: str):
# Keep only the last 6 messages visible
self.messages.append(msg)
if len(self.messages) > 6:
self.messages = self.messages[-6:]
self.update("\n".join(self.messages))
What does GameLog do?
It stores up to 6 recent messages and re-renders them each time a new one is added. This creates a scrolling combat log where you will see card plays and enemy actions.
- Now add the SpireApp class with its CSS layout below GameLog:
class SpireApp(App):
CSS = """
Screen {
layout: vertical;
}
#top-section {
height: auto;
}
#middle-section {
height: 1fr;
layout: horizontal;
}
#log-container {
width: 2fr;
}
#stats-container {
width: 1fr;
}
#bottom-section {
height: auto;
}
"""
What does this CSS do?
Textual uses CSS-like syntax to control layout. The screen stacks three sections vertically. The middle section splits horizontally: the log gets 2/3 width and stats gets 1/3. The top (enemy) and bottom (hand) sections size themselves to their content.
- Save app.py and confirm the module still loads cleanly:
python -c "from app import SpireApp; print('SpireApp loaded')"
You should see SpireApp loaded confirming no syntax errors.
- Complete SpireApp by adding the TITLE, constructor, compose method, on_mount, _refresh_ui, and the entry point. Paste this directly below the CSS string, still inside the SpireApp class:
TITLE = "Spire of Shadows"
def __init__(self):
super().__init__()
# Initialize a fresh game state
self.game = GameState()
def compose(self) -> ComposeResult:
yield Header()
with Container(id="top-section"):
yield EnemyPanel(id="enemy-panel")
with Horizontal(id="middle-section"):
with Container(id="log-container"):
yield GameLog()
with Container(id="stats-container"):
yield StatsPanel(id="stats-panel")
with Container(id="bottom-section"):
yield HandPanel(id="hand-panel")
yield Footer()
def on_mount(self):
self._refresh_ui()
self.query_one(GameLog).add_message("Battle begins! Defeat the enemy to win.")
def _refresh_ui(self):
self.query_one("#enemy-panel", EnemyPanel).render_enemy(self.game)
self.query_one("#stats-panel", StatsPanel).render_stats(self.game)
self.query_one("#hand-panel", HandPanel).render_hand(self.game)
if __name__ == "__main__":
app = SpireApp()
app.run()
What does this code do?
- compose() yields the widget tree: Header at the top, three layout sections in the middle, Footer at the bottom. Textual renders them in order.
- on_mount() fires once the app enters application mode. It calls _refresh_ui() to push the initial game state into every panel, then logs the opening message.
- _refresh_ui() uses query_one() to find each widget by its CSS ID and calls its render method with the current game state.
- Save app.py.
Before you run the app, what do you expect to see? Think about what the game state looks like at turn 1: 5 cards in hand, a full-health enemy, 3 energy.
- Launch the TUI by running:
python app.py
Your terminal transforms into a full-screen TUI. You should see the Corrupt Slime at the top with 30/30 HP and its first intent (Attack 8), your 5-card hand at the bottom with number labels and energy costs, and your player stats on the right showing 50 HP and 3 energy. Press Ctrl+Q to exit.
Why is the UI read-only?
You have not added any key bindings yet. Pressing number keys or E does nothing. The next step will wire up keyboard input so you can actually play cards and end your turn.
Seeing a blank screen or a crash?
If the app crashes with a widget-not-found error, make sure the CSS IDs in compose() match exactly: enemy-panel, stats-panel, hand-panel.
If you see garbled colors on macOS default Terminal, try running in iTerm2, Ghostty, Kitty, or WezTerm instead. The default macOS terminal is limited to 256 colors.
Help me debug my Textual app crash.
✔️ Awesome, I've got everything!
Great. Double check you have saved app.py and that pressing Ctrl+Q exits cleanly back to your normal terminal.
ⓧ I'd like to double check the full code
Scroll up and confirm your app.py contains all of the following sections, in this order:
- Docstring and imports (from textual and from game).
- EnemyPanel class with DEFAULT_CSS and render_enemy method.
- StatsPanel class with DEFAULT_CSS and render_stats method.
- HandPanel class with DEFAULT_CSS and render_hand method.
- GameLog class with DEFAULT_CSS, __init__, and add_message method.
- SpireApp class with CSS, TITLE, __init__, compose, on_mount, and _refresh_ui.
- The if __name__ == "__main__": entry point at the bottom.
Your game now has a polished visual layer. Every piece of game state is on screen: enemy health, your cards, your resources. Next up, you will add keyboard input so you can actually play those cards and battle the Corrupt Slime.
Make It Playable
In the previous step, you built a full-screen TUI that renders your game state. The enemy panel shows HP and intent, the hand panel shows your cards, and the stats panel tracks your energy and block.
There is one problem: you cannot actually play. Pressing keys does nothing. The UI is a static display with no input handling. In this step, you will wire keyboard input to the game engine so that pressing number keys plays cards and pressing E ends your turn.
In this step, get ready to:
- Add key bindings that map number keys 1-5 to playing cards, E to ending your turn, Q to quitting, and R to restarting.
- Implement the card-playing action with energy validation and UI updates.
- Implement the end-turn action that resolves the enemy's attack, checks win/lose conditions, and draws a new hand.
Add key bindings to the app
Textual uses a BINDINGS class variable to map keyboard keys to action methods. Each binding is a tuple of (key, action_name, description). When the learner presses the key, Textual automatically calls a method named action_ followed by the action name.
- In app.py, find the TITLE = "Spire of Shadows" line inside the SpireApp class.
- Add the following BINDINGS list directly below it:
BINDINGS = [
("1", "play_card(0)", "Card 1"),
("2", "play_card(1)", "Card 2"),
("3", "play_card(2)", "Card 3"),
("4", "play_card(3)", "Card 4"),
("5", "play_card(4)", "Card 5"),
("e", "end_turn", "End Turn"),
("q", "quit_game", "Quit"),
("r", "restart", "Restart"),
]
What does this code do?
Each tuple maps a key to an action. When you press 1, Textual calls action_play_card(0) (index 0 is the first card). The third element in each tuple is the label shown in the Footer widget.
The bindings declare WHAT keys do, but the actual logic lives in action methods you will write next.
Implement the play card action
When the player presses a number key, the app needs to validate the card is playable (enough energy, valid index), call the game engine's play_card() method, then refresh every panel to show the updated state.
- In app.py, scroll down to the end of the _refresh_ui method inside SpireApp.
- Add the following method directly below _refresh_ui:
def action_play_card(self, index: int):
# Ignore input if game is over
if self.game.game_over:
return
# Check if card is playable (valid index and enough energy)
if not self.game.can_play(index):
self.query_one(GameLog).add_message("Cannot play that card.")
return
# Play the card and log the result
message = self.game.play_card(index)
self.query_one(GameLog).add_message(message)
self._refresh_ui()
What does this code do?
- The game_over check prevents input after a win or loss.
- can_play() validates both the index and whether you have enough energy for that card's cost.
- play_card() resolves the card effect in the game engine and returns a message describing what happened.
- _refresh_ui() re-renders all panels so the energy, HP, and hand reflect the new state.
- Save app.py.
- Run the app to test card playing by running this command:
python app.py
- Press number keys 1 through 5 to play cards from your hand.
You should see the game log show messages like "Strike deals 6 damage!" and your energy counter decrease. The enemy HP bar should shrink as you deal damage. Cards disappear from your hand as you play them.
- Press Ctrl+Q to exit the app for now (the E key does not work yet because you have not written action_end_turn).
Cards not responding to key presses?
- Make sure BINDINGS is a class variable on SpireApp (indented at the class level, not inside a method).
- Check that the method is named exactly action_play_card with the action_ prefix. Textual will not find it otherwise.
- Verify the method takes self and index: int as parameters. The binding passes the card index as an argument.
Why aren't my key bindings working in Textual?
Implement end turn, quit, and restart
Playing cards is half the game loop. The other half is ending your turn, which triggers the enemy's action, resets your energy, and draws a new hand. You also need quit and restart actions so the player can exit or start over after a win or loss.
- In app.py, add these three methods directly below action_play_card:
def action_end_turn(self):
# Ignore input if game is over
if self.game.game_over:
return
# Resolve the enemy's turn and get log messages
messages = self.game.end_turn()
log = self.query_one(GameLog)
for msg in messages:
log.add_message(msg)
self._refresh_ui()
# Check if the game ended this turn
if self.game.game_over:
if self.game.victory:
log.add_message("[bold green]VICTORY! You defeated the enemy![/bold green]")
log.add_message("Press R to restart or Q to quit.")
else:
log.add_message("[bold red]DEFEAT! You have fallen.[/bold red]")
log.add_message("Press R to restart or Q to quit.")
def action_quit_game(self):
self.exit()
def action_restart(self):
# Reset the game state and clear the log
self.game = GameState()
self.query_one(GameLog).messages = []
self._refresh_ui()
self.query_one(GameLog).add_message("New battle begins!")
What does this code do?
- action_end_turn calls the game engine's end_turn() which discards your hand, executes the enemy intent (attack/defend/buff), ticks status durations, resets block, refills energy, and draws a fresh hand.
- After the enemy acts, it checks if the game ended. Victory shows a green message. Defeat shows a red message. Both prompt the player to press R or Q.
- action_quit_game calls self.exit() which cleanly exits the Textual application.
- action_restart creates a brand new GameState, clears the log, and refreshes the UI to start a fresh battle.
- Save app.py.
Before you run this, think about what you expect to happen when you press E after playing a few cards. Will the enemy always attack? What determines what the enemy does?
- Run the game by running this command:
python app.py
- Press number keys to play cards (watch your energy decrease and the enemy HP drop).
- Press E to end your turn.
The enemy takes its action based on its intent pattern. You should see a message like "Corrupt Slime attacks for 8 damage!" and your HP decrease. A new hand of cards appears and your energy resets to 3.
- Keep playing turns until you either reduce the enemy to 0 HP (victory) or your HP hits 0 (defeat).
- Press R to restart a new battle, then press Q to quit.
Game crashes when pressing E?
- Confirm your method is named action_end_turn (not action_end or end_turn).
- Make sure action_end_turn is defined at the same indentation level as action_play_card (inside the SpireApp class, not nested inside another method).
- If you see an error about GameLog, check that you are using self.query_one(GameLog) (the class name, not a string selector).
My Textual game crashes when I press E to end my turn. Help me debug.
✔️ Awesome, I've got everything!
Great. Double check you have saved app.py.
ⓧ I'd like to double check the full code
Your app.py file is longer than a single view, so confirm these sections are present in order inside your SpireApp class:
- CSS class variable (the layout styles).
- TITLE = "Spire of Shadows" followed by BINDINGS (8 key bindings).
- __init__, compose, on_mount, and _refresh_ui (from the previous step).
- action_play_card (validates and plays a card).
- action_end_turn (resolves enemy action, checks win/lose).
- action_quit_game (calls self.exit()).
- action_restart (creates fresh GameState, clears log, refreshes UI).
The widget classes above SpireApp (EnemyPanel, StatsPanel, HandPanel, GameLog) should be unchanged from the previous step.
Your card battle game is now fully playable. You can deal damage, block attacks, and fight through the enemy's intent pattern. In the next step, you will add status effects that make the Weaken and Dark Pact cards create meaningful strategic interactions.
Add Status Effects and Win/Lose Conditions
Your game is playable, but every Strike deals a flat 6 damage regardless of what happened on previous turns. There is no compounding power, no reason to sacrifice a turn for long-term gain, and no penalty for ignoring the enemy's buffs.
The status effect system you built into the game engine changes that. In this step, you will trace how Strength, Vulnerable, and Weak modify damage calculations, verify that Dark Pact actually scales your attacks across turns, and confirm that the game ends properly when either you or the enemy drops to 0 HP.
In this step, get ready to:
- Trace how status effects (Strength, Vulnerable, Weak) modify the damage formula.
- Verify that Dark Pact's Strength buff makes subsequent attacks deal more damage.
- Confirm the win and lose conditions trigger correctly and offer restart or quit.
Trace the damage calculation formula
Every attack in the game flows through a single method that applies status effects before dealing damage. This is where strategic depth lives.
- In VS Code, open game.py and find the _calculate_damage method:
def _calculate_damage(self, base: int, attacker, target) -> int:
damage = base + getattr(attacker, "strength", 0)
if self._has_status(attacker, "Weak"):
damage = int(damage * 0.75)
if self._has_status(target, "Vulnerable"):
damage = int(damage * 1.5)
return max(0, damage)
What does this formula do?
The calculation applies three modifiers in order:
- Strength adds flat damage to the base. A Strike with +2 Strength becomes 6 + 2 = 8.
- Weak on the attacker reduces total damage by 25%. An 8-damage attack becomes 6.
- Vulnerable on the target increases incoming damage by 50%. A 6-damage hit lands as 9.
- Now find the _tick_statuses method just below it:
def _tick_statuses(self, entity):
entity.statuses = [s for s in entity.statuses if s.duration > 0]
for s in entity.statuses:
s.duration -= 1
Why do statuses tick down?
At the end of each turn, every active status effect has its duration decremented by 1. When the duration reaches 0, it gets filtered out of the list. This means applying 2 turns of Vulnerable gives the player exactly 2 turns to exploit the bonus damage before it expires.
- Confirm the engine runs correctly by executing the test output at the bottom of game.py:
python game.py
You should see the enemy's name, HP, intent, your hand of 5 cards with costs and descriptions, and the draw/discard pile counts.
Seeing a ModuleNotFoundError?
Make sure you are running the command from inside your project folder (the same directory that contains game.py and app.py).
Help me debug my game.py import error.
Test the Strength scaling in the TUI
The printed output confirms the engine loads, but the real test is whether status effects change damage numbers during live gameplay. The Dark Pact card in your starting deck grants +2 Strength permanently, making every subsequent attack hit harder.
- Before you run the game, think about this: if you play Dark Pact (+2 Strength) and then play a Strike, what damage number do you expect to see in the game log?
- Launch the full TUI by running:
python app.py
- Look at your hand for the Dark Pact card. Note its position (1-5) in the list.
- Press the number key corresponding to Dark Pact to play it.
- Check the game log. You should see "Dark Pact grants +2 Strength!" and the Stats panel should now show "Strength: 2".
- Now play a Strike card from your hand.
The game log should show "Strike deals 8 damage!" instead of the usual 6. That extra 2 damage comes from your Strength buff. Every attack card you play for the rest of this fight benefits from that same +2 bonus.
Why is Strength permanent?
Dark Pact is a Power card. Powers are exhausted after play (they do not go to the discard pile). In exchange, their effect lasts the entire fight. This is the core tradeoff: spend 1 energy now for compounding value over every future turn.
Strike still showing 6 damage?
Make sure you played Dark Pact before the Strike. If you played Strike first, it uses the base value. Check the Stats panel on the right for "Strength: 2" to confirm Dark Pact resolved.
If your hand does not contain Dark Pact this turn (it only appears once in the 10-card deck), press E to end your turn and draw a new hand until it appears.
Help me figure out why Strength isn't applying to my attacks.
Verify win and lose conditions
A game without an ending is not a game. The engine detects victory when the enemy's HP hits 0, and defeat when your HP drops to 0. Each path needs to work correctly and offer a clear next action.
- Continue playing cards aggressively to deal damage to the Corrupt Slime (it has 30 HP total).
- Press E to end each turn when you run out of energy. Watch the enemy take its action based on its telegraphed intent.
- Keep attacking until the enemy's HP bar shows 0.
When the enemy reaches 0 HP, the game stops accepting card plays. The enemy panel shows an empty HP bar. Press R to start a fresh battle, or Q to exit the app.
- Press R to restart, then test the defeat path: play no Defend cards and press E repeatedly to let the enemy kill you.
When your HP hits 0, the game log shows a red "DEFEAT! You have fallen." message followed by "Press R to restart or Q to quit." This confirms both win and lose paths work end-to-end.
How does the enemy's intent system create strategy?
The enemy cycles through a fixed pattern: Attack 8, Attack 12, Defend 6, Buff +2 Str. Because you can always see the next move, you can plan ahead. If the enemy shows "Defend 6", playing a big attack wastes damage into their block. If it shows "Attack 12", you need Defend cards ready.
This is the "intent" system from deck-building roguelikes. Telegraphed enemy actions turn the game from random chance into a puzzle you can solve with the right card plays.
- Press Q to exit the game when you are done testing.
Game not responding to key presses?
If the game feels frozen after the enemy dies or you die, remember: the game_over flag blocks all actions except R (restart) and Q (quit). Press one of those keys.
If the TUI renders garbled or with missing characters, try a different terminal. The macOS default Terminal app supports 256 colors but some Unicode characters may render poorly. iTerm2, Ghostty, Kitty, or WezTerm give better results.
Help me fix my Textual TUI rendering issues.
Your card battle game now has real strategic depth. Every card choice matters because status effects compound across turns. You have built a complete game engine with interlocking mechanics, a polished TUI that displays all the relevant state, and win/lose conditions that close the game loop cleanly.
Secret mission
Draft a Card and Fight a Second Enemy
A single fight is fun, but real deck-building games grow your deck across encounters. Can you add a card reward screen after the first victory and pit the player against a harder second enemy with a different attack pattern?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with no cloud resources or paid services, so there are no ongoing costs.
Resources you used:
- Project files (game.py and app.py) in your project folder on your Desktop.
- Textual Python library installed via pip.
Keep everything running
No action needed. Choose this if you are still actively building or want to keep playing and experimenting with the game.
- Your project files stay on your Desktop, ready to run anytime with python app.py.
- Textual remains installed in your Python environment for future TUI projects.
Pause - I'll come back to this later
Since this project runs locally with no background processes, there is nothing to pause. Your files and Textual will be ready whenever you return.
- Close VS Code if you want to free up memory.
- Your project files and Textual installation persist until you explicitly delete them.
Delete - I don't want to use this again
Remove all project files and the Textual library to start fresh.
- Close any open files in VS Code by clicking File, then Close Folder.
- Open Finder (macOS) or File Explorer (Windows) and navigate to your Desktop.
- Delete the project folder that contains game.py and app.py. Right-click the folder and select Move to Trash (macOS) or Delete (Windows).
- Uninstall Textual by running this command in your terminal:
pip uninstall textual
Confirm with y when prompted.
Nice Work!
Nice Work!
You just built a fully playable deck-building card battle game that runs entirely in your terminal. From a pure-Python game engine to a polished Textual UI with reactive widgets, you architected a complex interactive application from scratch.
You've learned how to:
- Built a complete game engine with card data models, deck management (draw/discard cycling), an energy system, and turn resolution that separates logic from presentation.
- Created a polished terminal UI using Textual with CSS-styled widgets, keyboard-driven interaction, and a reactive refresh pattern that keeps all panels in sync with game state.
- Implemented an enemy AI intent system that telegraphs actions and interlocking status effects (Vulnerable, Weak, Strength) that create meaningful card synergies and strategic decision-making.
- Secret Mission: Extended the game with a card draft/reward system and a second harder encounter, implementing the core deck-building loop with screen transitions.
Ready to quiz yourself?