
Sorcery is a two-player, text-based collectible card game written in C++ for CS 247. Inspired by Hearthstone and Magic, it has minions, spells, enchantments, and rituals—all wired together with the classic Gang of Four design patterns. It was a school project, but it ended up being the most fun I've had writing C++.
I've loved card games for as long as I can remember. I grew up playing Pokémon TCG—building janky decks, trading cards at recess (including fake ones, so I guess I was a scammer), chasing full arts and secret rares I had no business spending my allowance on. There's something about the systems underneath a good card game that always fascinated me: the way a handful of simple rules combine into these deep, emergent interactions. So when CS 247 (our course on object-oriented software design) handed us a final project to build a collectible card game in C++, I was pretty excited.
The game is called Sorcery. Ahmed and I built it from scratch over a few weeks, and it turned into the project where all the design patterns we'd been learning about in the abstract became applicable.
Sorcery is a two-player, text-based card game in the spirit of Hearthstone and Magic. Each player starts with 20 life, spends magic as a resource, and tries to reduce their opponent to zero. There are four card types: minions you summon to the board, spells that fire a one-time effect, enchantments that modify minions, and rituals that sit in play and react to the game around them.
The fun is in the interactions. Minions have triggered abilities ("whenever a minion leaves play, gain +1/+1") and activated ones ("pay 1 magic: deal 1 damage to a target"). Enchantments stack on top of each other. Rituals quietly watch the board and respond to events.
The thing CS 247 drills into you is that good OO design is about finding the right seams. Every card type is different, but they all need to be created, copied, played, and reasoned about uniformly. We leaned on a handful of Gang of Four patterns, and each one solved a very specific, very real problem.
Decks are just text files—a list of card names, one per line. Somewhere we needed to turn the string "Bone Golem" into an actual fully-wired Minion object, abilities and all. A giant switch would have meant touching the same function every time we added a card and coupling the loader to every concrete class. Instead we used a Factory backed by a map of lambdas:
src/factory/CardFactory.ccstd::unique_ptr<Card> CardFactory::createCard(const std::string& name) { static std::unordered_map<std::string, std::function<std::unique_ptr<Card>()>> cardMap = { {"Bone Golem", []() { auto minion = std::make_unique<Minion>("Bone Golem", 2, 1, 3, "Gain +1/+1 whenever a minion leaves play."); minion->setTriggeredAbility(std::make_unique<BoneGolemTrigger>(minion.get())); return minion; }}, // ... every other card registers itself the same way }; auto it = cardMap.find(name); return it != cardMap.end() ? it->second() : nullptr; }
Each lambda is a little recipe: build the minion, attach its trigger, hand back ownership via unique_ptr. Adding a card is one new entry, and the rest of the engine never has to know it exists.
Here's a subtle one. A deck needs its own copies of cards, and effects like "resurrect the top minion in your graveyard" need to duplicate a card mid-game. But Card is an abstract base—you can't just copy-construct through a base pointer and expect the dynamic type (and its abilities) to come along. So every card implements clone():
src/cards/base/Minion.ccstd::unique_ptr<Card> Minion::clone() const { auto cloned = std::make_unique<Minion>(name, cost, baseAttack, baseDefence, description); if (triggeredAbility) cloned->setTriggeredAbility(triggeredAbility->clone()); if (activatedAbility) cloned->setActivatedAbility(activatedAbility->clone()); return cloned; }
This is the Prototype pattern, and it has to clone deeply—the abilities are owned by unique_ptr, so a shallow copy wouldn't even compile. Getting comfortable with this kind of ownership-aware copying was probably the single biggest thing C++ taught me on this project.
Enchantments were my favorite part. An enchantment like Enrage gives a minion *2/*2; Giant Strength gives +2/+2; Magic Fatigue makes its abilities cost more. They stack, they apply in order, and a spell like Disenchant can pop the most recent one off. This is textbook Decorator—but instead of physically wrapping the minion object, we kept the enchantments as a stack and fold the base stat through every modifier on read:
src/cards/base/Minion.ccint Minion::getAttack() const { int attack = baseAttack; for (const auto& enchantment : enchantments->getEnchantments()) { attack = enchantment->getModifiedAttack(attack); } return attack; }
Each decorator just defines how it transforms a value:
src/cards/enchantments/cards/Enrage.ccint EnrageDecorator::getModifiedAttack(int baseAttack) const { return baseAttack * 2; } int EnrageDecorator::getModifiedDefence(int baseDefence) const { return baseDefence * 2; }
I really like how this turned out. Order matters—*2 then +2 is different from +2 then *2—and because the list is ordered oldest-to-newest, the game rules fall out of the data structure for free. Even Silence is just an enchantment whose isAbilitySilenced() returns true, so "disable this minion's abilities" needed zero special-casing.
Triggered abilities and rituals are the reactive layer of the game—they fire in response to things happening elsewhere. That's the Observer pattern. A TriggerManager holds the observers, and the key insight is that a minion's TriggeredAbility is a TriggerObserver, so the same machinery drives both minions and rituals.
Events are just strings ("minionleaves", "start", "minionenters"). When something happens, the manager walks every observer—but card games are strict about ordering simultaneous effects. Magic calls it APNAP: Active Player, Non-Active Player. We baked that rule directly into the dispatch:
src/events/TriggerManager.ccvoid TriggerManager::processInAPNAPOrder(const std::string& event, Game* game) { // 1. Active player's minions, left to right for (Minion* minion : activePlayer->getBoard().getMinions()) { if (minion->hasTriggeredAbility() && !minion->isAbilitySilenced()) { auto* trigger = minion->getTriggeredAbility(); if (trigger->matchesTrigger(event)) trigger->notify(event, game); } } // 2. Active player's ritual, then 3. inactive minions, then 4. inactive ritual // ... }
So when a minion dies, the active player's Bone Golem grows before the opponent's does—deterministically, every time. The notification itself is tiny because each observer only knows about its own card:
src/abilities/triggers/BoneGolemTrigger.ccvoid BoneGolemTrigger::execute(Game* game) { source->setAttack(source->getAttack() + 1); source->setDefence(source->getDefence() + 1); }
The last piece is activated abilities. A minion's ability is a unit of behavior you can store, copy, and run later—so we modeled each one as a Command. The abstract AbilityCommand declares execute(), and the concrete ActivatedAbility just holds a std::function captured back in the factory:
src/abilities/ActivatedAbility.ccclass ActivatedAbility : public AbilityCommand { std::function<void(Target, Game*)> abilityFunction; void execute(Target target, Game* game) override { abilityFunction(target, game); } };
This cleanly separates invoking an ability from what it does. Minion::useAbility handles the boring shared logic—check for actions, check if silenced, fold the enchantments into the cost, pay the magic—and then blindly calls execute(). It has no idea whether the command deals damage or summons a token, and it doesn't need to.
C++ does not hold your hand. Coming from higher-level languages, the memory model was a wall I hit over and over. Who owns this minion—the board, the graveyard, or the trigger pointing at it? We standardized on unique_ptr for ownership and raw pointers for non-owning references, but it took a lot of segfaults and a lot of staring at die() (where a minion has to notify the trigger system, then remove itself from the board, then move itself into the graveyard—in that order) before the lifetime rules really sank in.
The other hard part was resisting the urge to special-case everything. The first time you implement Silence, the lazy instinct is to add an isSilenced flag to Minion. The patterns force you to think harder—and the payoff is that Silence, Disenchant, and stacking enchantments all end up being the same mechanism. That discipline is the actual lesson of the course, and it only clicked once I'd felt the pain of doing it the wrong way first.
Even though this was "just" a school project, it's one of the things I'm most proud of. Design patterns always felt like vocabulary you memorized for an exam until I had a real system where each one earned its place: Factory to decouple creation, Prototype to copy polymorphically, Decorator to stack modifiers, Observer to react to events in a defined order, Command to make behavior first-class. None of them are exotic—but feeling why each one exists, in a domain I genuinely cared about, is completely different from reading about them.
Mostly, this project reminded me why I got into building software in the first place. I was the kid memorizing Pokémon type matchups and card interactions, and here I was implementing that same machinery from the ground up—type by type, trigger by trigger. Getting to learn the fundamentals of C++ and OO design through a card game was about as fun as a course project gets, and it made me want to keep building things in this space.