diff --git a/content/_index.md b/content/_index.md index 7dff6cb..3ca60f8 100644 --- a/content/_index.md +++ b/content/_index.md @@ -3,4 +3,4 @@ title: codegirl.games description: I build games and show the craft along the way, documenting every step in public. --- -I aim to build games and show things I learn along the way by documenting my journey. +I document prototypes while I build them: what worked, what broke, what I’d do next. diff --git a/content/about.md b/content/about.md index 6807a90..f5738f9 100644 --- a/content/about.md +++ b/content/about.md @@ -1,10 +1,9 @@ --- title: About -description: A place where I build games and share the development process in public. +description: Games in progress. Notes in public. No finished portfolio cosplay. +layout: about --- -**Codegirl Games** is a place where I build games and share how they're made: devlogs, experiments, and lessons from real projects. +**Codegirl Games** is a workbench, not a highlight reel. -I'm not pretending to have all the answers. I'm learning in public: comparing languages on the same project, applying patterns from books like *Game Programming Patterns*, and building through trial and error. - -Whether you're starting out, leveling up, or just curious how games get made, you're in the right place. +I build prototypes and write down what the systems taught me — language comparisons, pattern experiments, broken paths, and the next attempt. The art stays honest. The learning stays public. diff --git a/content/posts/_index.md b/content/posts/_index.md index 78547b9..18f09f8 100644 --- a/content/posts/_index.md +++ b/content/posts/_index.md @@ -1,6 +1,6 @@ --- title: Posts -description: Devlogs, lessons, language comparisons, and book reviews from my game development work. +description: The build log — every note from prototypes in progress. --- -Devlogs, lessons, and experiments from my game development journey. +Dated entries from games I’m actually building. diff --git a/content/posts/colony-sim-kickoff.md b/content/posts/colony-sim-kickoff.md deleted file mode 100644 index 3e66975..0000000 --- a/content/posts/colony-sim-kickoff.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -title: "Starting a colony sim, lessons I'm carrying forward" -description: "Kicking off a new Odin prototype and the grid-game patterns I already trust: cameras, tilemaps, and how to lay out entity data." -date: 2026-07-03 -type: lesson -series: colony-sim-prototype -series_order: 1 -languages: ["odin"] -tags: ["odin", "colony-sim", "game-dev"] ---- - -I'm starting a second prototype, a colony simulation, in [Odin](https://odin-lang.org/) and Raylib. It's early days and there's not much game there yet. This post isn't a feature tour. It's the stuff I already know works because I learned it building the [tower defense prototype](https://github.com/Codegirl-Games/tower-defense-prototype) first. - -If you're starting a top-down grid game, these are the foundations I keep reaching for. - -## The 2D camera is just coordinate math - -A 2D camera doesn't need a library. It's two numbers and two functions: - -- **Offset**: which world point sits at the center of the screen -- **Zoom**: how many world pixels map to one screen pixel - -Convert world → screen: - -``` -screen = (world - offset) * zoom + screen_center -``` - -Convert screen → world (for mouse input): - -``` -world = (screen - screen_center) / zoom + offset -``` - -That's it. Panning moves the offset. The scroll wheel clamps zoom between sensible min/max values. Pan speed gets divided by zoom so movement feels consistent when you're zoomed in. - -The lesson I keep re-learning: **every mouse click must go through `screen_to_world` before you do anything useful.** Selection, movement commands, building placement: all of it. Forget this once and your clicks drift when the camera moves. - -## Tilemaps are flat arrays with helpers - -A tilemap is a width, a height, and a flat buffer. Tile `(x, y)` lives at index `y * width + x`. Wrap access in two helpers and never think about the math again: - -- `tile_index(map, x, y)`: buffer lookup -- `tile_in_bounds(map, x, y)`: guard every read and write - -World position to tile coordinate is just `floor(world / tile_size)` on each axis. I used the same pattern in both prototypes. The colony sim added a comment in `tile_in_bounds`, *"learning from another game, this will become handy"*, because I skipped it early in the tower defense project and paid for it later. - -Terrain type can start as a `u8` per tile. A switch or lookup table maps type → color. Don't over-engineer biomes on day one; get the grid drawing and the coordinate conversions right first. - -When drawing, multiply tile size by zoom and cull tiles that fall off-screen. An 80×60 map is 4,800 rectangles, fine for a prototype, but the cull pass is free and keeps the pattern honest for bigger maps. - -## Logical tiles vs visual position - -Grid games have two positions whether you plan for it or not: - -- **Logical position**: which tile the entity occupies (`Tile_Coord{3, 7}`) -- **Visual position**: where the sprite actually renders (smoothly interpolated between tile centers) - -The colonist's grid cell updates one step at a time. The circle on screen lerps toward the next tile center each frame. Gameplay stays discrete; motion looks continuous. Mix these up and pathfinding, collision, and selection all get harder. - -I didn't need this in the tower defense game: enemies moved in continuous world space along a path. Colony sims live on tiles. Separate the two early. - -## Struct of arrays vs array of structs - -Both layouts show up in my code. Neither is always wrong. - -**Array of structs (AoS)**, what the tower defense prototype uses: - -```odin -enemies: [MAX_ENEMIES]Enemy, -``` - -Each slot is a full `Enemy` struct: position, health, speed, active flag, all together. Natural to read: `enemy.health -= damage`. Good when you often touch most fields on one entity at once. - -**Struct of arrays (SoA)**, what the colony sim uses: - -```odin -Entity_World :: struct { - active: [MAX_ENTITIES]bool, - position: [MAX_ENTITIES]Tile_Coord, - move_state: [MAX_ENTITIES]Move_State, - visual_position: [MAX_ENTITIES]rl.Vector2, - // ... -} -``` - -Each field is a parallel array across all entities. Good when you update one system at a time (move every entity, then draw every entity) and when entities are sparse (lots of inactive slots in a fixed pool). - -My rule of thumb so far: - -| Reach for AoS when… | Reach for SoA when… | -|---|---| -| Entities are few and always accessed whole | You iterate one component across many entities | -| Struct fits in cache and you're touching most of it | Many slots are inactive (object pool) | -| Code clarity matters more than layout | Systems are split (movement, render, AI) | - -Both prototypes use **fixed pools** with an `active` flag, no allocate/free per spawn. That pattern transferred directly from tower defense to colony sim regardless of AoS vs SoA. - -## Entity handles, not raw indices - -The colony sim returns `Entity`, a `distinct u32`, instead of passing array indices around. Internally it's `index + 1`, with `0` meaning invalid. Small thing, but it stops you from accidentally passing a tile coordinate or a mouse value where an entity ID goes. - -## Update and draw stay separate - -Both games follow the same loop shape: - -1. Read input -2. Update simulation (`world_update`, `entity_update_movement`) -3. Draw (`tilemap_draw`, `entity_draw`) - -Simulation code never calls draw functions. Draw code never changes game state. Obvious, but worth stating because it's the seam that keeps things readable as files multiply. - -## What I'm not writing about yet - -Pathfinding, job queues, resources, building: none of that exists in the repo yet. When there's a full week of work to show, I'll write a proper devlog. For now, the [repo](https://github.com/Codegirl-Games/colony-sim-prototype) is a camera, a tilemap, one colonist, and a right-click move command. - -The game will come. These patterns are the part I'm confident in. diff --git a/content/posts/game-programming-patterns-review.md b/content/posts/game-programming-patterns-review.md deleted file mode 100644 index 62cbcd2..0000000 --- a/content/posts/game-programming-patterns-review.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -title: "Game Programming Patterns, first impressions" -description: "A book review of Robert Nystrom's Game Programming Patterns and how I use it in my tower defense prototype." -date: 2026-06-28 -type: books -book: "Game Programming Patterns" -tags: ["patterns", "books"] ---- - -*Game Programming Patterns* by Robert Nystrom is the reference I'm using as I build. The patterns aren't abstract; I'm already applying several of them in my [Odin tower defense prototype](https://github.com/Codegirl-Games/tower-defense-prototype). - -Here is where the book shows up in real code so far. - -## Patterns in the tower defense game - -### Object Pool - -Enemies and projectiles are never allocated per spawn. Both use fixed arrays (`[MAX_ENEMIES]Enemy`, `[MAX_PROJECTILES]Projectile`) with `acquire_enemy` and `acquire_projectile` scanning for inactive slots. A spawned unit resets its fields and sets `active = true`; on death or impact, `active = false` returns the slot to the pool. - -This was the first pattern I reached for, in week one. Every wave can spawn dozens of enemies and towers can fire many projectiles per second. Avoiding allocate/free in the hot path keeps the update loop predictable. - -### Command - -Player input goes through a command layer, not straight into game logic. `gather_commands` and `poll_controls_command` read keyboard and mouse state and return a `Command` value: `Place_Tower`, `Start_Wave`, `Upgrade_Tower`, or `None`. `execute_command` dispatches to the right handler. - -UI buttons, hotkeys, and mouse clicks all produce the same command type. Adding a new input source does not mean rewriting placement or wave logic. - -### Event Queue - -Gold changes do not happen inside combat code directly. When an enemy dies or a wave is cleared, systems call `push_event` with `Enemy_Killed` or `Wave_Survived`. At the end of `update_world`, `process_events` drains the queue and applies gold rewards. - -Combat systems announce what happened; the economy system decides what it costs. That separation kept the update loop readable as towers, waves, and enemy types piled on. - -### State - -The game runs in explicit phases: `Build`, `Combat`, `Game_Over`, and `Victory`. Phase controls what input is accepted (you cannot place towers during combat), when waves can start, and when the simulation stops updating. - -This is a straightforward state machine, not a deep AI behavior tree, but the same idea: one variable drives which rules apply this frame. - -### Update Method - -Each system owns an update function called once per frame from `update_world`: `update_wave`, `update_enemies`, `update_projectiles`, `update_towers`, `update_phase`. No single giant function walks every entity type inline. - -The book's "one game loop, many systems" structure maps cleanly onto separate `.odin` files as the project grew. - -### Game Loop - -`app.odin` runs the classic loop: read input, call `update_world`, render the map, entities, overlay, and controls. Update and draw stay separate; simulation code never calls draw functions. - -### Data Locality - -Enemies and projectiles live in contiguous fixed arrays rather than scattered heap allocations. I iterate the full pool each frame but skip inactive slots. Not as aggressive as a struct-of-arrays layout, but the fixed-buffer approach gives similar benefits: no pointer chasing, no allocator pressure during combat. - -### Type Object (archetype tables) - -Tower and enemy stats live in shared lookup tables (`TOWER_ARCHETYPES`, `ENEMY_DEF`), not duplicated on every instance. A placed tower stores its kind, position, cooldown, and upgrade level; range, damage, cost, and footprint come from the table row. - -Adding a cannon or a tank enemy is mostly a new table entry plus a behavior branch, not a new class hierarchy. - -### Subclass Sandbox - -Tower types differ by `switch t.kind` in `update_towers`: archers spawn homing projectiles, cannons fire ballistic shots with splash, ice towers apply slow on hit. I skipped inheritance trees in favor of enum variants and explicit branches. Nystrom argues this is the right call when you only have a handful of types and the differences are behavioral, not structural. - -### Spatial partition (grid) - -The map is a 2D tile grid (`Blocked`, `Path`, `Build`). Tower placement queries `can_build_at` and footprint overlap against grid cells, not against every entity on the map. A full spatial hash would be overkill at this scale; the grid already gives O(1) tile lookups for build rules. - -## What I have not used yet - -Patterns I expect to need later but have not implemented in this prototype: - -- **Pathfinding** for dynamic routes when tower placement blocks the path -- **Behavior trees** or a deeper **State** machine per enemy for complex AI -- **Observer** beyond the simple event queue (e.g. UI reacting to stat changes) -- **Component** or **Entity-Component-System** if entity types multiply significantly - -## Why I keep the book nearby - -Nystrom names the patterns, explains the tradeoffs, and shows when *not* to use them. That matches how I work: reach for Object Pool and Command early because the problem is obvious; hold off on ECS until the entity count justifies the complexity. - -Expect follow-up posts that go deeper on individual chapters as I apply more patterns to the tower defense game and the colony sim. diff --git a/content/posts/td-odin-week-1.md b/content/posts/td-odin-week-1.md deleted file mode 100644 index bb23f40..0000000 --- a/content/posts/td-odin-week-1.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -title: "Week 1: Tower defense in Odin, project kickoff" -description: "First week building my Odin tower defense prototype: grid map, object pool, commands, events, and a playable build/combat loop." -date: 2026-06-12 -type: devlog -series: tower-defense-prototype -series_order: 2 -languages: ["odin"] -tags: ["odin", "tower-defense", "devlog"] ---- - -First devlog in the tower defense series. This week I kicked off the [Odin prototype](https://github.com/Codegirl-Games/tower-defense-prototype) and went from a blank `main.odin` to a playable loop: build towers, start a wave, watch enemies walk the path, lose base health when they leak through. - -![Early map with build zone and L-shaped path](/images/tower-defense/td-week-1-map.png) - -## What I built - -**Day 1: map and loop.** Switched the project to Odin + Raylib. Added a fixed 30×20 tile grid with three tile kinds: blocked grass, a build zone, and a hand-authored L-shaped path. The game window is exactly map-sized (`MAP_W * TILE_SIZE`), and the main loop separates `update_world` from `render_world`. - -**Enemies that move.** Enemies spawn at the path start and step toward waypoints extracted from path tiles on the grid. Movement is simple vector math, no steering, no physics engine. - -**Object pool from day one.** Enemies live in a fixed `[MAX_ENEMIES]Enemy` array. `acquire_enemy` reuses inactive slots instead of allocating every spawn. This is the first [Game Programming Patterns](https://gameprogrammingpatterns.com/object-pool.html) idea I reached for, and it fit Odin naturally: explicit memory, no hidden allocations. - -**Commands and events.** Input goes through a small command layer (`Place_Tower`, `Start_Wave`). Gold changes go through an event queue (`Enemy_Killed`, `Wave_Survived`) so combat logic does not touch the economy directly. That separation made the update loop easier to read even before I had much gameplay. - -**Towers, waves, and phases.** By the end of the week I had one tower type (Archer) backed by a `Tower_Archetype` table: range, damage, fire rate, cost in one place. The game alternates between **Build** and **Combat** phases. Press `N` to start a wave; a spawner drips out enemies with scaling health and speed. Towers pick the nearest target in range and apply damage directly. Survive the wave, earn gold, place more towers. - -## What worked - -- Odin's struct enums and fixed arrays made the object pool straightforward, no fighting the language -- Data-driven tower archetypes: adding stats later did not require rewriting placement logic -- Command + event split kept `update_world` readable as systems piled on -- Raylib got something on screen fast; I spent the week on game logic, not boilerplate - -## What broke - -- **No projectiles yet.** Towers subtract health instantly. It plays, but it does not look or feel like a tower defense game yet -- **Path is not pathfinding.** `build_path` scans the grid for path tiles in row order. Fine for a hand-drawn map, useless once I want procedural levels or tower placement that reroutes enemies -- **HUD lives in the renderer.** Gold, base HP, wave count, and phase hints are drawn inline in `render_world`. Works for now, will get messy -- **Tower placement during combat.** The command gatherer had a duplicate mouse handler that let you place towers mid-fight. Small bug, easy miss - -## Repo snapshot - -By June 12 the `game/` package had separate files for map, path, enemies, towers, waves, events, commands, and rendering: about 450 lines added across the week. Not pretty, but the skeleton for every port (C, C++) is visible: fixed pools, explicit phases, data tables instead of inheritance trees. - -Next: projectiles, split out the HUD, and more tower types. - -
- -
diff --git a/content/posts/td-odin-week-2.md b/content/posts/td-odin-week-2.md deleted file mode 100644 index 4aa7f28..0000000 --- a/content/posts/td-odin-week-2.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -title: "Week 2: Projectiles, footprints, and a UI detour" -description: "Second week on the Odin tower defense prototype: homing projectiles, multi-tile towers, render split, and learning to stick with Raylib for UI." -date: 2026-06-19 -type: devlog -series: tower-defense-prototype -series_order: 3 -languages: ["odin"] -tags: ["odin", "tower-defense", "devlog"] ---- - -Week two on the [Odin prototype](https://github.com/Codegirl-Games/tower-defense-prototype). Last week ended with instant-hit archers and a keyboard-driven HUD. This week the game started to look like a tower defense: arrows fly, towers occupy real space on the grid, and I got my first proper UI, after a brief and expensive detour through third-party UI libraries. - -## What I built - -**Projectiles.** Archers no longer subtract health on the frame they fire. Towers spawn homing projectiles from a second object pool (`[MAX_PROJECTILES]Projectile`), track the target enemy by slot index, and deal damage on impact. Same pattern as the enemy pool from week one: acquire slot, reset fields, mark inactive when done. - -![Wave 1 combat with homing projectiles](/images/tower-defense/td-week-2-projectiles.png) - -**Render split.** `render_world` became a thin orchestrator: `render_map`, `render_enemies`, `render_towers`, `render_projectiles`. Each system owns its draw calls. Small refactor, big readability win as files grew. - -**Multi-tile towers.** Tower archetypes gained `footprint_w` and `footprint_h`. The archer is 1×2 tiles, taller than a single cell. Placement now checks whether the full rectangle fits on build tiles and does not overlap other towers. Rendering draws a gold rectangle sized to the footprint instead of a fixed 28×28 square. - -**Memory cleanup.** Added explicit `delete` calls for dynamic arrays (`path`, `towers`, `events`) when the game loop exits. Odin will not save you from leaking if you allocated with `append`. - -**Overlay bar.** Moved gold and base health out of scattered `DrawText` calls into `overlay.odin`, a top bar with consistent padding and colors. Wave count and combat hints stayed in `hud.odin` for now. - -**Control panel.** Bottom bar with clickable buttons: select Archer, start wave. `poll_controls_command` feeds into the existing command layer so UI clicks and keyboard shortcuts share one path. Added `utils.odin` with a reusable `draw_button` helper for centered labels. - -## What worked - -- Projectile pool mirrored the enemy pool: copy the pattern, ship faster -- Footprint-based placement forced me to think in grid coordinates early; multi-tower-type layouts will need this anyway -- Command layer absorbed UI input cleanly: buttons return `Command` values just like keyboard handlers -- Rip-and-replace on Clay was painful but left me with a simpler codebase than I started with - -## What broke - -- **UI library detour.** Tried ImGui bindings, then [Clay](https://github.com/nicklockwood/clay) for two days. Gold moved to Clay, start-wave became a Clay button, then I deleted all of it and rewrote the overlay in pure Raylib. Lesson: for a small game HUD, immediate-mode Raylib is enough. Do not import a layout engine until you have a layout problem. -- **Tower selection half-wired.** The Archer button sets `world.selected_tower`, but `execute_command` still hardcodes `.Archer` on placement. UI looks done; logic is not. -- **Split HUD.** Gold and health live in `overlay.odin`, wave/enemy count in `hud.odin`, controls in `controls.odin`. Three files for one screen; next cleanup pass needed. -- **Range bug (later fix).** A typo in the distance function made archer range longer than intended. Caught at the end of the week; fix landed June 26. - -## Repo snapshot - -June 13–19 added about 470 net lines across 18 files. New modules: `projectile.odin`, `overlay.odin`, `controls.odin`, `utils.odin`. Still one tower type, but the archer now shoots, occupies space, and has a shop button. - -Next: end screens, fullscreen, more tower types, and enemy variety. - diff --git a/content/posts/td-odin-week-3.md b/content/posts/td-odin-week-3.md deleted file mode 100644 index 3d3964e..0000000 --- a/content/posts/td-odin-week-3.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -title: "Week 3: Fullscreen, variety, and splash damage" -description: "Third week on the Odin tower defense prototype: end screens, render-to-texture scaling, three tower types, enemy archetypes, wave recipes, upgrades, and cannon splash." -date: 2026-06-26 -type: devlog -series: tower-defense-prototype -series_order: 4 -languages: ["odin"] -tags: ["odin", "tower-defense", "devlog"] ---- - -Week three on the [Odin prototype](https://github.com/Codegirl-Games/tower-defense-prototype). Week two left me with one tower, one enemy, and a HUD split across three files. This week the prototype started feeling like a game: win/lose screens, resizable fullscreen, three tower types, three enemy types, wave recipes, and cannons that splash. - -## What I built - -**End screens and unified overlay.** Added `endscreen.odin` for game over and victory overlays. Moved wave count and enemy totals into `overlay.odin` and deleted the leftover `hud.odin` split. One top bar for economy and combat status; end screens dim the world and show the result. - -**Fullscreen via render texture.** New `display.odin` renders the fixed 960×640 game world into a `RenderTexture`, then scales it to whatever window size the player uses. `F11` toggles fullscreen. Mouse coordinates go through `game_mouse()` so clicks still map to grid tiles when the viewport letterboxes. The game logic stays pixel-fixed; only presentation scales. - -**Three tower types.** Archer (homing arrows), Cannon (2×2 footprint, ballistic arc), Ice (slow effect, smaller footprint). Each has its own archetype row: cost, range, fire rate, footprint, upgrade caps. Shop buttons in `controls.odin` grew to match: select tower, click the map, place. - -**Tower preview.** Hovering the build grid while a tower is selected draws a ghost footprint before you commit gold. Small UX win that made placement feel less like guessing. - -**Enemy variety.** Replaced the single grunt with archetypes: Grunt, Runner (fast, fragile), Tank (slow, thick). Each scales health and speed with wave number and gets its own color. - -**Wave recipes.** Waves are no longer `5 + wave * 2` of the same enemy. `build_wave_recipe` returns an ordered list of `(kind, count)` entries: wave 1 is six grunts, wave 2 mixes grunts and runners, later waves add tanks. The spawner drips through the recipe entry by entry. - -**Tower upgrades.** Towers track `upgrade_level`. Stats scale via `tower_stats_at_level`: extra damage and range per level, paid from gold during build phase. - -**Ballistic projectiles and splash.** Projectiles gained a `Projectile_Mode`: homing for archers, ballistic for cannons. Cannon shots fly in a fixed direction; on impact they call `apply_splash_damage` in a radius defined on the archetype. First time area damage changed how I thought about placement: kill zones, not just single targets. - -**World refactor.** Split `World` into nested structs for economy, combat, and wave state. Gold and enemies moved out of flat fields. Cleaner ownership before more systems land. - -## What worked - -- Render-to-texture scaling solved fullscreen without rewriting every coordinate -- Enemy and tower archetype tables made adding Runner/Tank/Cannon mostly data changes -- Wave recipes are easy to read and tweak; no code change to reshuffle wave 3 -- Projectile modes reused the same pool; ballistic and homing share acquire/update/render paths - -## What broke - -- **Half-finished tower rollout.** Cannon and Ice landed before projectiles and shop buttons caught up; several commits of "no projectiles, no button" in the log -- **Wave recipe memory leak.** Forgetting to `delete` the old recipe before building a new spawner leaked dynamic arrays every wave start. Fixed same day -- **Path length check inside the enemy loop.** A `return` on short paths aborted the entire update instead of skipping one enemy. Moved the guard outside the loop -- **Range typo.** A bug in `distance` made archer range longer than intended, caught and fixed at the end of the week along with a range buff - -## Repo snapshot - -June 20–26 added about 650 net lines across 16 files. New modules: `display.odin`, `endscreen.odin`. Deleted: `hud.odin`. Three towers, three enemies, two projectile modes, and a win/lose loop. - -Next: tests, more refactors, and cleaning up the control panel as tower count grows. diff --git a/content/posts/td-odin-week-4.md b/content/posts/td-odin-week-4.md deleted file mode 100644 index 46166eb..0000000 --- a/content/posts/td-odin-week-4.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -title: "Week 4: Tests, refactors, and data tables" -description: "Fourth week on the Odin tower defense prototype: splitting tower placement, table-driven defs, first tests, and undoing a nested world refactor." -date: 2026-06-29 -type: devlog -series: tower-defense-prototype -series_order: 5 -languages: ["odin"] -tags: ["odin", "tower-defense", "devlog"] ---- - -Week four on the [Odin prototype](https://github.com/Codegirl-Games/tower-defense-prototype). Week three shipped fullscreen, three tower types, enemy variety, and splash damage. This week was quieter on features and heavier on structure: splitting files, pushing logic into data tables, and writing my first tests. - -## What I built - -**Split tower logic.** `tower.odin` had grown to handle combat, stats, rendering, and placement. I pulled placement into `tower_placement.odin`: footprint checks, overlap tests, `try_place_tower`, ghost preview. Combat and archetypes stay in `tower.odin`. Placement is geometry; combat is timing and targeting. Keeping them separate made both files easier to navigate. - -**Data tables over switch statements.** Several `get_*` functions became lookup tables: - -- `TILE_COLORS` replaced a `tile_color` switch -- `ENEMY_DEF` replaced per-kind switch logic with base stats, per-wave scaling, draw radius, and gold reward in one row per enemy -- Tower archetypes dropped a redundant `kind` field and gained a `color` column for rendering - -Tweaking tank gold from 5 to 10 or archer cost became a one-line edit instead of hunting through switch arms. - -**Constants consolidation.** UI layout lived in helper functions that recalculated button rectangles every frame. Moved to `constants.odin`: `ARCHER_BUTTON_RECT`, `START_WAVE_BUTTON_RECT`, `STARTING_GOLD`, `STARTING_BASE_HEALTH`, overlay colors, control bar dimensions. `controls.odin` got shorter and the layout stopped drifting. - -**First tests.** Added `math2d_test.odin` (distance, `move_toward`) and `wave_test.odin` (start wave, clear wave, recipe progression). Small suite, but it caught a wave recipe memory leak during gold rebalancing: the kind of bug I'd fixed once by hand and reintroduced during a refactor. - -**Economy pass.** Gold rewards moved into enemy definitions. Tank kills pay more than grunts. Tower costs live in archetype rows. Balanced enough to play-test without obvious snowball or stall. - -**World struct simplified.** Flattened `base_health` and map init into cleaner helpers. Started the week by reverting the nested `Economy` / `Combat` / `Wave` sub-structs from week three, back to a flat `World` with direct field access. - -## What worked - -- Splitting placement from combat scaled better as tower logic grew -- Table-driven defs made adding and tuning enemy/tower stats mostly data changes -- Tests paid for themselves immediately on wave spawner edge cases -- Flat `World` struct was easier to reason about than nested sub-structs for a game this size - -## What broke - -- **Nested world refactor, reverted.** Week three split `World` into `Economy`, `Combat`, and `Wave` sub-structs. Looked clean on paper. Every system suddenly needed `world.combat.enemies` instead of `world.enemies`, accessors multiplied, and nothing got simpler. Reverted on June 27. Don't refactor structure until the current shape is actually hurting you, and wait until you have tests. - -- **Test cleanup is manual.** Odin tests that allocate dynamic arrays need explicit `defer delete`. Forgot once; leak showed up in the test runner, not the game. - -## Repo snapshot - -June 27–29 touched 14 files, ~490 net lines. New modules: `tower_placement.odin`, `math2d_test.odin`, `wave_test.odin`. Three towers, three enemies, wave recipes, upgrades, splash damage, fullscreen, end screens, plus a test suite to build on. - -Next: pathfinding, more tower shop wiring, ice slow polish, and whatever breaks once I add a fourth tower type. diff --git a/content/posts/theprimeagen-spire-deckbuilder.md b/content/posts/theprimeagen-spire-deckbuilder.md deleted file mode 100644 index f2d08fe..0000000 --- a/content/posts/theprimeagen-spire-deckbuilder.md +++ /dev/null @@ -1,125 +0,0 @@ ---- -title: "Building a browser deckbuilder in vanilla JavaScript" -description: "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." -date: 2026-07-04 -type: lesson -languages: ["javascript"] -tags: ["javascript", "patterns", "deckbuilder", "web"] ---- - -Last year I built a parody *Slay the Spire* game in the browser as a birthday gift for [ThePrimeagen](https://www.twitch.tv/theprimeagen). It started as a joke. The [repo](https://github.com/codegirl-007/theprimeagen-spire) 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](https://github.com/Codegirl-Games/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: - -```javascript -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 to `localStorage`, 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 events` shows 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*](https://gameprogrammingpatterns.com/) 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.js` was 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](https://github.com/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. diff --git a/hugo.toml b/hugo.toml index 0c52d50..e93c91c 100644 --- a/hugo.toml +++ b/hugo.toml @@ -1,14 +1,26 @@ baseURL = 'https://codegirl.games/' -languageCode = 'en-me' title = 'codegirl.games' theme = 'codegirl' +defaultContentLanguage = 'en' + +[languages] + [languages.en] + locale = 'en-US' + label = 'English' + weight = 1 [params] logo = 'codegirl.games' - logo_image = '/images/logo.png' + logo_image = '/images/logo-header.png' + logo_image_full = '/images/logo.png' description = 'I build games and show the craft along the way, documenting every step in public.' tagline = 'Building games and showing what I learn' author = 'Codegirl Games' + og_image = '/images/og-default.png' + +[taxonomies] + tag = 'tags' + series = 'series' [markup] [markup.goldmark] diff --git a/static/images/apple-touch-icon.png b/static/images/apple-touch-icon.png new file mode 100644 index 0000000..74f1578 Binary files /dev/null and b/static/images/apple-touch-icon.png differ diff --git a/static/images/favicon-16.png b/static/images/favicon-16.png new file mode 100644 index 0000000..c893a8f Binary files /dev/null and b/static/images/favicon-16.png differ diff --git a/static/images/favicon-32.png b/static/images/favicon-32.png new file mode 100644 index 0000000..3831f76 Binary files /dev/null and b/static/images/favicon-32.png differ diff --git a/static/images/favicon.ico b/static/images/favicon.ico new file mode 100644 index 0000000..124133d Binary files /dev/null and b/static/images/favicon.ico differ diff --git a/static/images/logo-header.png b/static/images/logo-header.png new file mode 100644 index 0000000..d394a20 Binary files /dev/null and b/static/images/logo-header.png differ diff --git a/static/images/og-default.png b/static/images/og-default.png new file mode 100644 index 0000000..01bfc36 Binary files /dev/null and b/static/images/og-default.png differ diff --git a/themes/codegirl/layouts/_default/about.html b/themes/codegirl/layouts/_default/about.html new file mode 100644 index 0000000..17c197a --- /dev/null +++ b/themes/codegirl/layouts/_default/about.html @@ -0,0 +1,28 @@ +{{ define "main" }} +
+
+
+ {{ with .Site.Params.logo_image_full }} + + {{ end }} +
+

{{ .Title }}

+ {{ with .Description }} +

{{ . }}

+ {{ end }} +
+
+
+
{{ .Content }}
+
+ Open the build log +
+
+{{ end }} diff --git a/themes/codegirl/layouts/_default/baseof.html b/themes/codegirl/layouts/_default/baseof.html index c6ee1de..b9e2275 100644 --- a/themes/codegirl/layouts/_default/baseof.html +++ b/themes/codegirl/layouts/_default/baseof.html @@ -1,11 +1,12 @@ - + {{ partial "head.html" . }} + {{ partial "header.html" . }} -
+
{{ block "main" . }}{{ end }}
{{ partial "footer.html" . }} diff --git a/themes/codegirl/layouts/_default/list.html b/themes/codegirl/layouts/_default/list.html index fb4b542..7cb65ae 100644 --- a/themes/codegirl/layouts/_default/list.html +++ b/themes/codegirl/layouts/_default/list.html @@ -1,17 +1,22 @@ {{ define "main" }}
+

Index

{{ .Title }}

{{ with .Content }}
{{ . }}
{{ end }}
- +
{{ end }} diff --git a/themes/codegirl/layouts/_default/single.html b/themes/codegirl/layouts/_default/single.html index 2638456..d61542b 100644 --- a/themes/codegirl/layouts/_default/single.html +++ b/themes/codegirl/layouts/_default/single.html @@ -1,5 +1,5 @@ {{ define "main" }} -
+
{{ end }} - {{ with .Params.series }} -

Part of {{ . }}

+ {{ with .GetTerms "series" }} + {{ range . }} +

Part of {{ .LinkTitle }}

+ {{ end }} + {{ else }} + {{ with .Params.series }} +

Part of {{ replace . "-" " " | title }}

+ {{ end }} {{ end }} {{ with .Params.video_url }}
@@ -29,5 +35,27 @@ {{ end }}
{{ .Content }}
+ + {{ with .Params.series }} + {{ $seriesName := . }} + {{ $siblings := where (where $.Site.RegularPages "Section" "eq" "posts") "Params.series" "eq" $seriesName }} + {{ $sorted := $siblings.ByDate }} + {{ if gt (len $sorted) 1 }} + + {{ end }} + {{ end }}
{{ end }} diff --git a/themes/codegirl/layouts/_default/taxonomy.html b/themes/codegirl/layouts/_default/taxonomy.html new file mode 100644 index 0000000..7dfcc92 --- /dev/null +++ b/themes/codegirl/layouts/_default/taxonomy.html @@ -0,0 +1,20 @@ +{{ define "main" }} +
+
+

Series

+

Active builds

+

Prototype threads I’m writing while I build them.

+
+ +
+{{ end }} diff --git a/themes/codegirl/layouts/_default/term.html b/themes/codegirl/layouts/_default/term.html new file mode 100644 index 0000000..ee234ae --- /dev/null +++ b/themes/codegirl/layouts/_default/term.html @@ -0,0 +1,23 @@ +{{ define "main" }} +
+
+

{{ if eq .Data.Singular "series" }}Series{{ else if eq .Data.Singular "tag" }}Tag{{ else }}{{ .Data.Singular | humanize }}{{ end }}

+

{{ .Title }}

+ {{ with .Content }} +
{{ . }}
+ {{ end }} + {{ with .Description }} + {{ if not $.Content }} +

{{ . }}

+ {{ end }} + {{ end }} +
+
    + {{ range .Pages.ByDate.Reverse }} +
  • + {{ partial "post-card.html" . }} +
  • + {{ end }} +
+
+{{ end }} diff --git a/themes/codegirl/layouts/index.html b/themes/codegirl/layouts/index.html index b88f26c..ca7197c 100644 --- a/themes/codegirl/layouts/index.html +++ b/themes/codegirl/layouts/index.html @@ -1,22 +1,86 @@ {{ define "main" }} -
-

{{ .Site.Params.tagline }}

-

{{ .Title }}

- {{ with .Content }} -
{{ . }}
+{{ $featuredHref := "/posts/" | relURL }} +{{ with .Params.featured_series }} + {{ $featuredHref = printf "/series/%s/" . | relURL }} +{{ end }} +{{ $hasSeries := gt (len .Site.Taxonomies.series) 0 }} +{{ $posts := where .Site.RegularPages "Section" "eq" "posts" }} + +
+ {{ with .Params.featured_image }} + + {{ end }} -
-
-
-

Latest

- All posts → -
-
    - {{ range where .Site.RegularPages "Section" "eq" "posts" | first 12 }} -
  • - {{ partial "post-card.html" . }} -
  • +
    +

    {{ .Site.Params.tagline }}

    +

    {{ .Title }}

    + {{ with .Content }} +
    {{ . }}
    {{ end }} -
+
+ {{ if or .Params.featured_series .Params.featured_label }} + + {{ with .Params.featured_label }}{{ . }}{{ else }}Enter series{{ end }} + + {{ end }} + Read the log + About +
+ {{ with .Params.featured_caption }} +

{{ . }}

+ {{ end }} + +
+ +{{ if $hasSeries }} +
+
+

Active builds

+
+
{{ end }} + +{{ if gt (len $posts) 0 }} +
+
+

Build log

+ All entries → +
+
    + {{ range first 12 $posts }} +
  1. + + + {{ with .Params.type }}{{ . }}{{ else }}note{{ end }} + {{ .Title }} + +
  2. + {{ end }} +
+
+{{ end }} +{{ end }} diff --git a/themes/codegirl/layouts/partials/fonts.html b/themes/codegirl/layouts/partials/fonts.html new file mode 100644 index 0000000..b15abcf --- /dev/null +++ b/themes/codegirl/layouts/partials/fonts.html @@ -0,0 +1 @@ + diff --git a/themes/codegirl/layouts/partials/footer.html b/themes/codegirl/layouts/partials/footer.html index 911ffc0..3ed4110 100644 --- a/themes/codegirl/layouts/partials/footer.html +++ b/themes/codegirl/layouts/partials/footer.html @@ -1,9 +1,14 @@ -