This commit is contained in:
2026-08-15 10:38:53 -07:00
parent 68dfdfa33b
commit 8d43f5259b
30 changed files with 1002 additions and 768 deletions
+1 -1
View File
@@ -3,4 +3,4 @@ title: codegirl.games
description: I build games and show the craft along the way, documenting every step in public. 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 Id do next.
+4 -5
View File
@@ -1,10 +1,9 @@
--- ---
title: About 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. 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.
Whether you're starting out, leveling up, or just curious how games get made, you're in the right place.
+2 -2
View File
@@ -1,6 +1,6 @@
--- ---
title: Posts 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 Im actually building.
-117
View File
@@ -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.
@@ -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.
-56
View File
@@ -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.
<figure class="post__video">
<iframe
class="post__iframe"
src="https://www.youtube-nocookie.com/embed/MKBSuTkClys"
title="Week 1: Tower defense in Odin, project kickoff"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
</figure>
-49
View File
@@ -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 1319 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.
-52
View File
@@ -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 2026 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.
-51
View File
@@ -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 2729 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.
@@ -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 <a href="https://theprimeagenbirthday.com" target="_blank" rel="noopener noreferrer">theprimeagenbirthday.com</a>.
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 30Sep 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 3Sep 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 <a href="https://theprimeagenbirthday.com" target="_blank" rel="noopener noreferrer">theprimeagenbirthday.com</a>; 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 <a href="https://theprimeagenbirthday.com" target="_blank" rel="noopener noreferrer">theprimeagenbirthday.com</a>. 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.
+14 -2
View File
@@ -1,14 +1,26 @@
baseURL = 'https://codegirl.games/' baseURL = 'https://codegirl.games/'
languageCode = 'en-me'
title = 'codegirl.games' title = 'codegirl.games'
theme = 'codegirl' theme = 'codegirl'
defaultContentLanguage = 'en'
[languages]
[languages.en]
locale = 'en-US'
label = 'English'
weight = 1
[params] [params]
logo = 'codegirl.games' 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.' description = 'I build games and show the craft along the way, documenting every step in public.'
tagline = 'Building games and showing what I learn' tagline = 'Building games and showing what I learn'
author = 'Codegirl Games' author = 'Codegirl Games'
og_image = '/images/og-default.png'
[taxonomies]
tag = 'tags'
series = 'series'
[markup] [markup]
[markup.goldmark] [markup.goldmark]
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 655 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

@@ -0,0 +1,28 @@
{{ define "main" }}
<article class="about">
<div class="about__banner">
<div class="about__banner-inner container">
{{ with .Site.Params.logo_image_full }}
<img
class="about__logo"
src="{{ . | relURL }}"
alt="{{ $.Site.Params.logo | default $.Site.Title }}"
width="512"
height="512"
decoding="async"
>
{{ end }}
<div class="about__intro">
<h1 class="about__title">{{ .Title }}</h1>
{{ with .Description }}
<p class="about__lead">{{ . }}</p>
{{ end }}
</div>
</div>
</div>
<div class="about__body container container--narrow content">{{ .Content }}</div>
<div class="about__cta-wrap container container--narrow">
<a class="stage__cta" href="{{ "/posts/" | relURL }}">Open the build log</a>
</div>
</article>
{{ end }}
+3 -2
View File
@@ -1,11 +1,12 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ .Site.LanguageCode }}"> <html lang="{{ with .Site.Language.Locale }}{{ . }}{{ else }}en{{ end }}">
<head> <head>
{{ partial "head.html" . }} {{ partial "head.html" . }}
</head> </head>
<body class="page"> <body class="page">
<a class="skip-link" href="#main">Skip to content</a>
{{ partial "header.html" . }} {{ partial "header.html" . }}
<main class="page__main"> <main id="main" class="page__main">
{{ block "main" . }}{{ end }} {{ block "main" . }}{{ end }}
</main> </main>
{{ partial "footer.html" . }} {{ partial "footer.html" . }}
+9 -4
View File
@@ -1,17 +1,22 @@
{{ define "main" }} {{ define "main" }}
<section class="section container"> <section class="section container">
<header class="section__header"> <header class="section__header">
<p class="section__eyebrow">Index</p>
<h1 class="section__title">{{ .Title }}</h1> <h1 class="section__title">{{ .Title }}</h1>
{{ with .Content }} {{ with .Content }}
<div class="section__intro content">{{ . }}</div> <div class="section__intro content">{{ . }}</div>
{{ end }} {{ end }}
</header> </header>
<ul class="post-list"> <ol class="log__list">
{{ range .Pages.ByDate.Reverse }} {{ range .Pages.ByDate.Reverse }}
<li class="post-list__item"> <li class="log__item">
{{ partial "post-card.html" . }} <a class="log__row" href="{{ .RelPermalink }}">
<time class="log__date" datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "060102" }}</time>
<span class="log__type">{{ with .Params.type }}{{ . }}{{ else }}note{{ end }}</span>
<span class="log__name">{{ .Title }}</span>
</a>
</li> </li>
{{ end }} {{ end }}
</ul> </ol>
</section> </section>
{{ end }} {{ end }}
+30 -2
View File
@@ -1,5 +1,5 @@
{{ define "main" }} {{ define "main" }}
<article class="post container"> <article class="post container container--narrow">
<header class="post__header"> <header class="post__header">
<p class="post__meta"> <p class="post__meta">
{{ with .Params.type }}<span class="post__type">{{ humanize . }}</span>{{ end }} {{ with .Params.type }}<span class="post__type">{{ humanize . }}</span>{{ end }}
@@ -10,8 +10,14 @@
{{ with .Params.languages }} {{ with .Params.languages }}
<p class="post__tags">{{ delimit . ", " }}</p> <p class="post__tags">{{ delimit . ", " }}</p>
{{ end }} {{ end }}
{{ with .GetTerms "series" }}
{{ range . }}
<p class="post__series">Part of <a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a></p>
{{ end }}
{{ else }}
{{ with .Params.series }} {{ with .Params.series }}
<p class="post__series">Part of {{ . }}</p> <p class="post__series">Part of <a href="{{ printf "/series/%s/" . | relURL }}">{{ replace . "-" " " | title }}</a></p>
{{ end }}
{{ end }} {{ end }}
{{ with .Params.video_url }} {{ with .Params.video_url }}
<figure class="post__video"> <figure class="post__video">
@@ -29,5 +35,27 @@
{{ end }} {{ end }}
</header> </header>
<div class="post__body content">{{ .Content }}</div> <div class="post__body content">{{ .Content }}</div>
{{ with .Params.series }}
{{ $seriesName := . }}
{{ $siblings := where (where $.Site.RegularPages "Section" "eq" "posts") "Params.series" "eq" $seriesName }}
{{ $sorted := $siblings.ByDate }}
{{ if gt (len $sorted) 1 }}
<nav class="series-nav" aria-label="More in this series">
<p class="series-nav__label">In this series</p>
<ol class="series-nav__list">
{{ range $sorted }}
<li class="series-nav__item{{ if eq $.RelPermalink .RelPermalink }} series-nav__item--current{{ end }}">
{{ if eq $.RelPermalink .RelPermalink }}
<span class="series-nav__current">{{ .Title }}</span>
{{ else }}
<a class="series-nav__link" href="{{ .RelPermalink }}">{{ .Title }}</a>
{{ end }}
</li>
{{ end }}
</ol>
</nav>
{{ end }}
{{ end }}
</article> </article>
{{ end }} {{ end }}
@@ -0,0 +1,20 @@
{{ define "main" }}
<section class="section container">
<header class="section__header">
<p class="section__eyebrow">Series</p>
<h1 class="section__title">Active builds</h1>
<p class="section__intro">Prototype threads Im writing while I build them.</p>
</header>
<div class="workbench__grid">
{{ range .Pages }}
<a class="workbench__cell" href="{{ .RelPermalink }}">
<span class="workbench__count">{{ len .Pages }} {{ if eq (len .Pages) 1 }}log{{ else }}logs{{ end }}</span>
<span class="workbench__name">{{ .LinkTitle }}</span>
{{ with .Description }}
<span class="workbench__desc">{{ . }}</span>
{{ end }}
</a>
{{ end }}
</div>
</section>
{{ end }}
@@ -0,0 +1,23 @@
{{ define "main" }}
<section class="section container">
<header class="section__header">
<p class="section__eyebrow">{{ if eq .Data.Singular "series" }}Series{{ else if eq .Data.Singular "tag" }}Tag{{ else }}{{ .Data.Singular | humanize }}{{ end }}</p>
<h1 class="section__title">{{ .Title }}</h1>
{{ with .Content }}
<div class="section__intro content">{{ . }}</div>
{{ end }}
{{ with .Description }}
{{ if not $.Content }}
<p class="section__intro">{{ . }}</p>
{{ end }}
{{ end }}
</header>
<ul class="post-list">
{{ range .Pages.ByDate.Reverse }}
<li class="post-list__item">
{{ partial "post-card.html" . }}
</li>
{{ end }}
</ul>
</section>
{{ end }}
+78 -14
View File
@@ -1,22 +1,86 @@
{{ define "main" }} {{ define "main" }}
<section class="hero container"> {{ $featuredHref := "/posts/" | relURL }}
<p class="hero__eyebrow">{{ .Site.Params.tagline }}</p> {{ with .Params.featured_series }}
<h1 class="hero__title">{{ .Title }}</h1> {{ $featuredHref = printf "/series/%s/" . | relURL }}
{{ end }}
{{ $hasSeries := gt (len .Site.Taxonomies.series) 0 }}
{{ $posts := where .Site.RegularPages "Section" "eq" "posts" }}
<section class="stage{{ if not .Params.featured_image }} stage--plain{{ end }}">
{{ with .Params.featured_image }}
<div class="stage__plane" aria-hidden="true">
<img
class="stage__image"
src="{{ . | relURL }}"
alt=""
width="960"
height="639"
loading="eager"
decoding="async"
>
</div>
<div class="stage__veil" aria-hidden="true"></div>
{{ end }}
<div class="stage__slab container">
<p class="stage__eyebrow">{{ .Site.Params.tagline }}</p>
<h1 class="stage__title">{{ .Title }}</h1>
{{ with .Content }} {{ with .Content }}
<div class="hero__body content">{{ . }}</div> <div class="stage__body">{{ . }}</div>
{{ end }} {{ end }}
<div class="stage__actions">
{{ if or .Params.featured_series .Params.featured_label }}
<a class="stage__cta" href="{{ $featuredHref }}">
{{ with .Params.featured_label }}{{ . }}{{ else }}Enter series{{ end }}
</a>
{{ end }}
<a class="stage__{{ if or $.Params.featured_series $.Params.featured_label }}ghost{{ else }}cta{{ end }}" href="{{ "/posts/" | relURL }}">Read the log</a>
<a class="stage__ghost" href="{{ "/about/" | relURL }}">About</a>
</div>
{{ with .Params.featured_caption }}
<p class="stage__caption">{{ . }}</p>
{{ end }}
</div>
</section> </section>
<section class="latest container">
<header class="latest__header"> {{ if $hasSeries }}
<h2 class="latest__title">Latest</h2> <section class="workbench container" aria-label="Active series">
<a class="latest__link" href="{{ "/posts/" | relURL }}">All posts →</a> <header class="workbench__header">
<h2 class="workbench__title">Active builds</h2>
</header> </header>
<ul class="post-list"> <div class="workbench__grid">
{{ range where .Site.RegularPages "Section" "eq" "posts" | first 12 }} {{ range $term, $pages := .Site.Taxonomies.series }}
<li class="post-list__item"> {{ $termPage := $.Site.GetPage (printf "/series/%s" $term) }}
{{ partial "post-card.html" . }} <a class="workbench__cell" href="{{ with $termPage }}{{ .RelPermalink }}{{ else }}{{ printf "/series/%s/" $term | relURL }}{{ end }}">
</li> <span class="workbench__count">{{ len $pages }} {{ if eq (len $pages) 1 }}log{{ else }}logs{{ end }}</span>
<span class="workbench__name">{{ with $termPage }}{{ .LinkTitle }}{{ else }}{{ replace $term "-" " " | title }}{{ end }}</span>
{{ with $termPage }}
{{ with .Description }}
<span class="workbench__desc">{{ . }}</span>
{{ end }} {{ end }}
</ul> {{ end }}
</a>
{{ end }}
</div>
</section> </section>
{{ end }} {{ end }}
{{ if gt (len $posts) 0 }}
<section class="log container">
<header class="log__header">
<h2 class="log__title">Build log</h2>
<a class="log__all" href="{{ "/posts/" | relURL }}">All entries →</a>
</header>
<ol class="log__list">
{{ range first 12 $posts }}
<li class="log__item">
<a class="log__row" href="{{ .RelPermalink }}">
<time class="log__date" datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "060102" }}</time>
<span class="log__type">{{ with .Params.type }}{{ . }}{{ else }}note{{ end }}</span>
<span class="log__name">{{ .Title }}</span>
</a>
</li>
{{ end }}
</ol>
</section>
{{ end }}
{{ end }}
@@ -0,0 +1 @@
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&family=Pixelify+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
+7 -2
View File
@@ -1,9 +1,14 @@
<footer class="site-footer container"> <footer class="site-footer">
<div class="site-footer__inner"> <div class="site-footer__inner container">
<p class="site-footer__mark">{{ .Site.Params.logo | default .Site.Title }}</p>
<p class="site-footer__tagline">{{ .Site.Params.tagline }}</p> <p class="site-footer__tagline">{{ .Site.Params.tagline }}</p>
<nav class="site-footer__nav" aria-label="Footer"> <nav class="site-footer__nav" aria-label="Footer">
<a class="site-footer__link" href="{{ "/posts/" | relURL }}">Posts</a> <a class="site-footer__link" href="{{ "/posts/" | relURL }}">Posts</a>
<a class="site-footer__link" href="{{ "/series/" | relURL }}">Series</a>
<a class="site-footer__link" href="{{ "/about/" | relURL }}">About</a> <a class="site-footer__link" href="{{ "/about/" | relURL }}">About</a>
{{ with .Site.Home.OutputFormats.Get "RSS" }}
<a class="site-footer__link" href="{{ .RelPermalink }}">RSS</a>
{{ end }}
</nav> </nav>
<p class="site-footer__copy">&copy; {{ now.Year }} {{ .Site.Params.author }}</p> <p class="site-footer__copy">&copy; {{ now.Year }} {{ .Site.Params.author }}</p>
</div> </div>
+26 -2
View File
@@ -1,8 +1,32 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }} · {{ .Site.Title }}{{ end }}</title> <title>{{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }} · {{ .Site.Title }}{{ end }}</title>
<meta name="description" content="{{ with .Description }}{{ . }}{{ else }}{{ .Site.Params.description }}{{ end }}"> {{ $desc := .Description | default .Site.Params.description }}
<meta name="description" content="{{ $desc }}">
<link rel="canonical" href="{{ .Permalink }}">
{{ $ogImage := .Site.Params.og_image | default "/images/og-default.png" }}
{{ with .Params.featured_image }}{{ $ogImage = . }}{{ end }}
{{ with .Params.images }}{{ with index . 0 }}{{ $ogImage = . }}{{ end }}{{ end }}
<meta property="og:site_name" content="{{ .Site.Title }}">
<meta property="og:title" content="{{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }}{{ end }}">
<meta property="og:description" content="{{ $desc }}">
<meta property="og:type" content="{{ if .IsPage }}article{{ else }}website{{ end }}">
<meta property="og:url" content="{{ .Permalink }}">
<meta property="og:image" content="{{ $ogImage | absURL }}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }}{{ end }}">
<meta name="twitter:description" content="{{ $desc }}">
<meta name="twitter:image" content="{{ $ogImage | absURL }}">
<link rel="icon" href="{{ "images/favicon.ico" | relURL }}" sizes="any">
<link rel="icon" type="image/png" sizes="32x32" href="{{ "images/favicon-32.png" | relURL }}">
<link rel="apple-touch-icon" href="{{ "images/apple-touch-icon.png" | relURL }}">
{{ with .OutputFormats.Get "RSS" }}
<link rel="alternate" type="application/rss+xml" title="{{ $.Site.Title }}" href="{{ .RelPermalink }}">
{{ end }}
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> {{ partial "fonts.html" . }}
<link rel="stylesheet" href="{{ "css/style.css" | relURL }}"> <link rel="stylesheet" href="{{ "css/style.css" | relURL }}">
+7 -5
View File
@@ -1,13 +1,15 @@
<header class="site-header container"> <header class="site-header">
<a class="site-header__logo" href="{{ "/" | relURL }}"> <div class="site-header__bar container">
<a class="site-header__brand" href="{{ "/" | relURL }}">
{{ with .Site.Params.logo_image }} {{ with .Site.Params.logo_image }}
<img class="site-header__logo-image" src="{{ . | relURL }}" alt="{{ $.Site.Params.logo | default $.Site.Title }}"> <img class="site-header__logo-image" src="{{ . | relURL }}" alt="">
{{ else }}
{{ $.Site.Params.logo }}
{{ end }} {{ end }}
<span class="site-header__wordmark">{{ .Site.Params.logo | default .Site.Title }}</span>
</a> </a>
<nav class="site-nav" aria-label="Main"> <nav class="site-nav" aria-label="Main">
<a class="site-nav__link{{ if eq .Section "posts" }} site-nav__link--active{{ end }}" href="{{ "/posts/" | relURL }}">Posts</a> <a class="site-nav__link{{ if eq .Section "posts" }} site-nav__link--active{{ end }}" href="{{ "/posts/" | relURL }}">Posts</a>
<a class="site-nav__link{{ if eq .Section "series" }} site-nav__link--active{{ end }}" href="{{ "/series/" | relURL }}">Series</a>
<a class="site-nav__link{{ if and .IsPage (eq .File.BaseFileName "about") }} site-nav__link--active{{ end }}" href="{{ "/about/" | relURL }}">About</a> <a class="site-nav__link{{ if and .IsPage (eq .File.BaseFileName "about") }} site-nav__link--active{{ end }}" href="{{ "/about/" | relURL }}">About</a>
</nav> </nav>
</div>
</header> </header>
@@ -5,5 +5,16 @@
</p> </p>
<h3 class="post-list__title">{{ .Title }}</h3> <h3 class="post-list__title">{{ .Title }}</h3>
{{ with .Description }}<p class="post-list__excerpt">{{ . }}</p>{{ end }} {{ with .Description }}<p class="post-list__excerpt">{{ . }}</p>{{ end }}
{{ with .Params.series }}<p class="post-list__series">{{ . }}</p>{{ end }}
</a> </a>
{{ $seriesTerms := .GetTerms "series" }}
{{ if $seriesTerms }}
{{ range $seriesTerms }}
<p class="post-list__series">
<a class="post-list__series-link" href="{{ .RelPermalink }}">{{ .LinkTitle }}</a>
</p>
{{ end }}
{{ else if .Params.series }}
<p class="post-list__series">
<a class="post-list__series-link" href="{{ printf "/series/%s/" .Params.series | relURL }}">{{ replace .Params.series "-" " " | title }}</a>
</p>
{{ end }}
File diff suppressed because it is too large Load Diff