9.3 KiB
title, description, date, type, languages, tags
| title | description | date | type | languages | tags | |||||
|---|---|---|---|---|---|---|---|---|---|---|
| Building a browser deckbuilder in vanilla JavaScript | How a birthday gift became a 6,000-line roguelike deckbuilder: state machines, commands, data tables, and what the commit history looks like when you ship anyway. | 2026-07-04 | lesson |
|
|
Last year I built a parody Slay the Spire game in the browser as a birthday gift for ThePrimeagen. It started as a joke. The repo ended up at ~6,400 lines of JavaScript across 60 files, with two acts, dozens of cards, community-written birthday messages, and a full roguelike loop you can play without a build step.
You can play it at theprimeagenbirthday.com.
This post is not a feature tour. It is how the project was built, what the git history actually shows, and where Game Programming Patterns shows up outside my Odin prototypes.
What shipped
The game is a static site: index.html, ES modules, CSS split by screen. No React, no bundler in production (npm was added briefly for tests, then removed).
The loop matches StS closely enough to feel familiar:
- Branching map with battles, elites, shops, rest sites, and events
- Turn-based combat with energy, block, weak/vulnerable, and intents
- Deck building: strike/defend staples plus dev-themed cards (
Terminal Coffee Rush,Production Deploy,Code Review) - Relics with hook functions (
onTurnStart,onDamageTaken, etc.) - Two acts with different enemy rosters and map layouts
- Win/lose screens, mid-run saves to
localStorage, and a pre-launch countdown that blocked play until September 9, 2025
The flavor is extremely online. Enemies are stream/community in-jokes. Events quote Lewis and Tolkien. The victory screen unlocks birthday messages from people who sent notes for Prime. That part is personal. The architecture underneath is reusable.
What the commits look like
There are 88 commits from August 30, 2025 to March 11, 2026. Rough phases:
Week one (Aug 30–Sep 2): gameplay exists. The initial commit already added ~7,600 lines: map, battle engine, card UI, styling. After that it was iteration: card costs, enemy tuning, keyboard vs mouse fixes, double-tap to play cards, swipe sounds, acts, and a commit literally titled think this is the final gameplay commit.
Week two (Sep 3–Sep 10): content and polish. Birthday messages landed in batches (Birthday Messages, phpeepee!, casey!, DHH!, ken wheeler and AOP). Bug fixes for deck initialization, event HP/energy leaks, block reset between turns. UI passes on the battle screen and welcome message. Balance commits: nerf act 2, nerf dax, Un-nerf act 2w.
Week two, structure (Sep 8): implement state machine. Gameplay was already there; this commit extracted map, battle, shop, rest, event, victory, defeat, and relic selection into discrete states. That refactor made the rest of the project maintainable.
Quiet period, then March 2027 prep. From September to March the repo sat mostly idle. Then a concentrated refactor week: split client vs shared code, moved data files, serialized shop/reward state for future networking, WebP assets, save behavior fixes (fix block leak between enemy turn), and a 1,700-line Cloudflare co-op design doc (tutorial.md) for authoritative multiplayer later.
The history is not a clean agile epic. It is burst development, meme commit messages, and a second pass when you already know the game works.
Architecture that held up
State machine for screens
GameStateMachine registers one class per screen: MAP, BATTLE, REWARD, SHOP, REST, EVENT, VICTORY, DEFEAT, RELIC_SELECTION. Each state implements enter, exit, render, and optional save/restore hooks.
Battle mid-run resume works because BattleState.getSaveData() persists the enemy, flags, and battleInProgress. On load, bootstrap checks whether you were mid-fight and routes back into combat instead of dropping you on the map with orphaned state.
This is the same State pattern I use for Build/Combat phases in the tower defense prototype, applied to UI flow instead of simulation phases.
Command pattern for input
Player actions go through command objects (PlayCardCommand, EndTurnCommand, MapMoveCommand, etc.) executed by a CommandInvoker. Keyboard shortcuts, mouse clicks, and shop buttons all funnel into the same paths.
That separation mattered when input got fancy: single number press raises a card, double press plays it. InputManager handles code review picks, shop purchases, and map navigation without duplicating game rules in event listeners.
Data tables for content
Cards, enemies, relics, and map nodes live in plain JS objects:
coffee_rush: {
id: "coffee_rush",
name: "Terminal Coffee Rush",
cost: 0,
type: "skill",
effect: (ctx) => { /* ... */ },
}
Adding content means adding rows, not subclass trees. Enemy AI is mostly (turn) => ({ type, value }) functions. Relics use optional hook objects. The Code Review card sets pendingCodeReview on the root; battle render and InputManager know how to show the pick-one-of-three overlay.
Same idea as TOWER_ARCHETYPES and ENEMY_DEF in Odin: behavior stays in code, stats and identity stay in tables.
Shared vs client split (March refactor)
Late refactors moved simulation-ish code under src/shared/ (engine/, data/, game/) and kept DOM/render/input under src/client/. The goal was a future where a Cloudflare Durable Object owns authoritative state while the browser keeps rendering.
Multiplayer never shipped. The split still made the codebase easier to reason about: battle math does not live beside innerHTML templates.
Bugs the commits keep fixing
Roguelikes hide nasty state bugs. This repo is no exception:
- Block leaking between turns. Fixed in separate commits for player and enemy turn boundaries. Block must reset at turn start; missing one side means silent damage inflation.
- Mid-battle saves. Saving
_battleInProgress, enemy HP, and hand state tolocalStorage, then restoring on reload. Easy to get wrong when most testing happens in one sitting. - Event modifiers vs combat.
fix health bug and energy bug inside eventsshows how one-shot screens can corrupt run state if they touch player stats outside the battle engine's expectations.
The ?screen=battle and ?screen=shop URL params plus mock player data in bootstrap.js were added so individual screens could be tested without playing to them every time. Worth copying for any UI-heavy browser game.
Performance passes
Late commits focused on load and layout cost: WebP conversion, dropping aggressive image preload, caching swipe sound, reducing layout churn during battle animations. For a static birthday game, this was optional polish. For a public deploy on slow mobile networks, it matters.
How this connects to my other work
I keep Game Programming Patterns nearby while building the Odin prototypes. This JavaScript project applies several of the same ideas in a different shape:
| Pattern | Here | Tower defense (Odin) |
|---|---|---|
| State | Screen flow (map, battle, shop) | Build / Combat / Game Over |
| Command | PlayCard, EndTurn, MapMove | Place_Tower, Start_Wave |
| Update method | Per-state render() + battle engine steps |
update_enemies, update_towers, etc. |
| Data locality | Plain objects, shuffle/draw on arrays | Fixed pools, archetype tables |
Different language, same instinct: separate input from rules, separate screens from simulation, push content into data.
What I would do differently
- State machine earlier. Gameplay landed first; the refactor on Sep 8 touched 15 files. Starting with states would have hurt day-one momentum but saved mid-project pain.
- Smaller render files from the start.
render.jswas enormous before feature folders (battleRender,mapRender, etc.) split it up. - Link the live site in the README. The game runs at theprimeagenbirthday.com; the repo should say that up front next to the clone instructions.
- Move the repo under the studio org. It still lives at
codegirl-007/theprimeagen-spire; my prototypes now live under Codegirl-Games.
Worth a post?
Yes, but as a lesson, not a weekly devlog. There is no neat week-by-week timeline after launch week. The value is architectural: how far vanilla JS gets you, what patterns transfer to Odin, and an honest commit log that includes Ligma balls next to implement state machine.
Play at theprimeagenbirthday.com. To explore the code, start at src/client/app/bootstrap.js for the state registration, src/shared/engine/battle.js for combat rules, and src/shared/data/cards.js for content shape. Multiplayer design notes are in tutorial.md if you want to see the planned next step that never left the design doc.
The game was a gift. The structure is the part worth stealing for the next project.