<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Gamedev on hippotion</title><link>https://blog.hippotion.com/tags/gamedev/</link><description>Recent content in Gamedev on hippotion</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Fri, 18 Jul 2025 00:00:00 +0000</lastBuildDate><atom:link href="https://blog.hippotion.com/tags/gamedev/index.xml" rel="self" type="application/rss+xml"/><item><title>📊 I Added a Stats Service to My Game to Answer One Question. It Multiplied.</title><link>https://blog.hippotion.com/posts/dice-and-shrines-stats/</link><pubDate>Fri, 18 Jul 2025 00:00:00 +0000</pubDate><guid>https://blog.hippotion.com/posts/dice-and-shrines-stats/</guid><description>Building a telemetry backend for Dice &amp;amp; Shrines — every attack logged, every guardian tracked, every die rolled accounted for. What the data revealed about balance, luck, and how people actually play.</description><content:encoded><![CDATA[<h2 id="the-problem">The problem</h2>
<p>I built <a href="/posts/dice-and-shrines">Dice &amp; Shrines</a> with five asymmetric guardian characters. Each one has a different passive and active ability that changes how reinforcements distribute, which territories you can attack, and what happens when you take damage.</p>
<p>The question I couldn&rsquo;t answer from just playing was: <strong>are they actually balanced?</strong></p>
<p>Not &ldquo;do they feel different&rdquo; — they obviously do. But is Fox&rsquo;s stored critical actually overpowered? Is Turtle&rsquo;s loss-recovery passive strong enough to matter, or is it just flavour? Is there a first-mover advantage baked into the map structure?</p>
<p>You can&rsquo;t answer questions like these from vibes. You need data. So I built a stats service.</p>
<hr>
<h2 id="what-gets-recorded">What gets recorded</h2>
<p>Every game produces five event types, posted as fire-and-forget HTTP calls from the game client to <code>game-stats.hippotion.com/event</code>:</p>
<p><strong><code>map_generated</code></strong> — logged when the map generator accepts a map. Records territory count, average territory size, minimum size, and how many generation attempts it took. This tells me how often the generator discards its own work and whether the acceptance criteria are too strict.</p>
<p><strong><code>game_start</code></strong> — fired when a game begins. Captures the number of players, the guardian assigned to each slot, and which slot is human. Returns a <code>gameId</code> that travels with the game for the rest of its life.</p>
<p><strong><code>attack</code></strong> — fired on every single dice roll. Attacker, defender, from-territory, to-territory, how many dice each side had, what they rolled, who won. This is the raw material for the probability analysis.</p>
<p><strong><code>elimination</code></strong> — fired when a player is knocked out. Records which guardian they were and how many players remained, so I can tell who exits first and who makes the final stand.</p>
<p><strong><code>game_end</code></strong> — fired on win or abandon. Records the winner&rsquo;s guardian, how many turns the game took, and whether it was abandoned.</p>
<p>The service is a FastAPI app backed by PostgreSQL, running in the homelab on the same k3s cluster as the game. About 150 lines of Python plus a schema.sql that the app runs on startup.</p>
<hr>
<h2 id="the-dashboard">The dashboard</h2>
<p>The stats dashboard is a single-page HTML response from <code>/</code> — self-contained, no external framework, chart.js for the visualisations. It polls <code>/api/stats</code> every 30 seconds and updates in place.</p>
<p>What it shows:</p>
<p><strong>Overview cards</strong>: total games, games today, games this week, human win rate, average turns per game, overall attack win rate, abandoned game count.</p>
<p><strong>Activity charts</strong>: games per day (last 7 days), game duration distribution in 10-turn buckets.</p>
<p><strong>Death spiral analysis</strong>: when players abandon (broken into phases: instant, early, mid-early, mid, late), and first-mover advantage — win percentage by player slot 0 through 5.</p>
<p><strong>Attack behaviour</strong>: the dice margin chart is the most interesting one. It shows attack volume and win rate for every possible attacker-dice-minus-defender-dice value, from strongly negative (attacker is outmatched) to strongly positive. Overlaid: a win rate line. You can see the actual probability curve emerging from real games and compare it to what the math predicts.</p>
<p><strong>Guardian intelligence</strong>: win rate, pick count, average attacks per game, survival rate to turn 50+, and average turns per winning game — per guardian, human players only.</p>
<p><strong>Elimination intelligence</strong>: when the first player gets knocked out per game, and a guardian fate table showing average elimination order and first-out percentage. Earliest-exiting guardian is surfaced explicitly.</p>
<p><strong>Map influence</strong>: territory count versus average game length. Also an attack efficiency heatmap — win rate for every attacker-dice × defender-dice combination, 1 through 8, rendered as a colour grid.</p>
<p><img alt="Attack efficiency heatmap — win rate for every attacker × defender dice combination" loading="lazy" src="/posts/dice-and-shrines-stats/stats-heatmap.png"></p>
<p><strong>Recent games</strong>: last 15 games with the human player&rsquo;s guardian, result, and IP address so I can tell if it&rsquo;s me testing or an actual player who wandered in.</p>
<hr>
<h2 id="what-the-data-showed">What the data showed</h2>
<p>The attack win rate across all games sits just under 60%. That&rsquo;s higher than a naive analysis suggests it should be — if both sides roll fairly, equal dice should be near-even. The explanation is selection bias: players only attack when they have a dice advantage. Nobody sends 2 dice at 8 dice repeatedly. The average attack has a positive margin, so the average win rate is above 50%.</p>
<p>The margin chart made this explicit. The plurality of attacks have a margin of +2 or more. The sub-zero margin attacks — technically losing plays — are a real but small fraction, usually late-game desperation or deliberate tempo plays.</p>
<p><img alt="Attack risk profile: dice margin distribution with win rate overlay — real data from the dashboard" loading="lazy" src="/posts/dice-and-shrines-stats/stats-margin.png"></p>
<p><strong>Human vs AI attack quality</strong> turned out to be the sharpest comparison. Humans and AI have different average margins. The AI is greedy but disciplined about attack selection; humans sometimes take gambles the AI wouldn&rsquo;t. You can see it in the numbers.</p>
<p><strong>First-mover advantage</strong> is measurable but not massive. Player slot 0 (goes first) has a slightly higher win rate than the average. Slots at the higher end of turn order are somewhat depressed. Not broken, but real — and a useful thing to watch if I ever add a competitive mode.</p>
<p><strong>Guardian balance</strong>: the win rate gap between the best and worst guardian tells me whether the balance is within acceptable range or a concern. The dashboard calls it out explicitly: if the gap exceeds 15 percentage points, it flags it as a balance issue. That threshold is arbitrary, but it forces a decision rather than letting drift accumulate unnoticed.</p>
<p><img alt="Guardian stats for human players — the 27.7pp win rate gap between Hippo and Fox, flagged as a balance concern" loading="lazy" src="/posts/dice-and-shrines-stats/stats-guardians.png"></p>
<p><strong>Abandonment phases</strong>: most abandonments are instant — the player clicked &ldquo;new game&rdquo; before actually playing. The interesting number is mid-game abandonment, which is a proxy for death spirals: you see your income drop, you know you&rsquo;re losing, you close the tab. That&rsquo;s a design signal, not just a metric.</p>
<hr>
<h2 id="designing-for-measurement">Designing for measurement</h2>
<p>The useful insight from building this is that it changes how you design the game. Once you know every attack is being logged, you start thinking about what the attack data will tell you. Shrines give territories a guaranteed die — does that show up in attack margins near shrine territories? I didn&rsquo;t add territory-topology tracking, but I could. The schema is just a few columns away.</p>
<p>The same goes for guardian abilities. Fox&rsquo;s stored critical fires at turn boundaries — I log turn number on every attack, so I can look for Fox spikes in attack win rate on certain turns. I haven&rsquo;t run that query yet, but the data is there if the balance question becomes sharp enough to need it.</p>
<p>That&rsquo;s the thing about adding observability to something you built yourself: you stop guessing about whether it&rsquo;s working and start reading the evidence. The game got more interesting to design once I could see what was actually happening inside it.</p>
<hr>
<h2 id="the-stack">The stack</h2>
<ul>
<li><strong>FastAPI</strong> — event intake and stats API, ~150 lines</li>
<li><strong>PostgreSQL</strong> — five tables: maps, games, game_guardians, attacks, eliminations</li>
<li><strong>chart.js</strong> — dashboard visualisations, loaded from CDN</li>
<li><strong>k3s + Argo CD</strong> — deployed as a Kubernetes pod, Dockerised, managed GitOps alongside everything else on the homelab</li>
</ul>
<p>Source at <a href="https://github.com/janos-gyorgy/dice-n-shrines-stats">dice-n-shrines-stats</a>.</p>
]]></content:encoded></item><item><title>🎲 I Built a Browser Game to Learn AI Coding Tools. It Turned Into Something Else.</title><link>https://blog.hippotion.com/posts/dice-and-shrines/</link><pubDate>Fri, 04 Jul 2025 00:00:00 +0000</pubDate><guid>https://blog.hippotion.com/posts/dice-and-shrines/</guid><description>What started as a Claude Code / Codex sandbox became a territory conquest game with five asymmetric guardians, procedurally generated hex maps, and a stats service to balance them. Here&amp;rsquo;s what happened.</description><content:encoded><![CDATA[<h2 id="it-started-as-a-sandbox">It started as a sandbox</h2>
<p>I wanted to get a feel for AI-assisted coding tools — Claude Code, Codex — in a low-stakes environment where breaking things was fine. A browser game seemed like the right vehicle: self-contained, no prod database, no users to disappoint.</p>
<p>I picked a premise I knew was fun: <strong>Dice Wars</strong>. The Flash-era classic. Roll dice to attack adjacent territories, biggest army snowballs. Simple enough that I could focus on the tooling rather than the design. Or so I thought.</p>
<p>Six weeks later I had five asymmetric character classes, a procedural hex map generator with acceptance criteria, a FastAPI telemetry service recording every dice roll, and a stat dashboard I check more than I probably should. The tooling became a background concern. The game took over.</p>
<hr>
<h2 id="how-the-game-works">How the game works</h2>
<p>The rules are genuinely minimal.</p>
<p>You start with a random slice of a procedurally generated map — a patchwork of irregular coloured territories, each one a cluster of hex tiles that reads as a solid blob. You and up to seven opponents begin scattered across it. Objective: own everything.</p>
<p><strong>Attacking</strong> is one click. Select your territory, click an adjacent enemy territory. Both sides roll all their dice and sum. Higher total wins. Attacker wins: you capture the territory, your dice advance in minus one. Defender wins: your attack is repelled, you&rsquo;re reduced to a single die on the attacking territory.</p>
<p><strong>Reinforcements</strong> are the mechanic that makes this a strategy game. At the end of your turn, you receive bonus dice equal to the size of your <strong>largest contiguous group of territories</strong>. Not total territories — the biggest connected blob. Fragmented territory generates almost nothing. A solid connected bloc snowballs.</p>
<p>That one rule creates the entire strategic texture. Grab fast but stay connected. Chokepoints are worth defending at a loss. Cutting an opponent in half collapses their income immediately. The late game turns into tense standoffs until one roll cracks something open.</p>
<p><img alt="An 8-player Epic game in progress — territories changing hands, rankings shifting in the side panel" loading="lazy" src="/posts/dice-and-shrines/midgame.png"></p>
<hr>
<h2 id="the-shrines">The shrines</h2>
<p>Early on I added shrines — special territories marked with a ★. They behave differently from normal territories in a few ways:</p>
<ul>
<li><strong>Higher dice cap</strong>: normal territories max out at 8 dice, shrines at 10</li>
<li><strong>Minimum floor</strong>: a shrine never drops below 2 dice after attacking, win or loss — it can&rsquo;t be stripped bare</li>
<li><strong>Guaranteed reinforcement</strong>: the shrine gets a die first at end of turn, before random distribution</li>
<li><strong>Aura</strong>: each of your own territories adjacent to the shrine gets a +1 guaranteed die (shown with a dim ◆ indicator)</li>
</ul>
<p>The shrine mechanic does something interesting to the risk calculus. Holding a shrine isn&rsquo;t just a territory — it&rsquo;s a node that warps the value of everything adjacent to it. You&rsquo;ll defend an aura territory harder than a territory of equivalent size elsewhere on the map, because losing it means losing the aura bonus. It also means attacking <em>into</em> a shrine aura is expensive: the shrine can&rsquo;t be worn down easily, and the neighboring territories keep refilling.</p>
<p>Shrines turned out to be the moment the math got interesting.</p>
<hr>
<h2 id="five-guardians">Five guardians</h2>
<p>The game has a character select screen. Each player — human or AI — picks a guardian before the map generates. Five options, each with a passive and an active ability that meaningfully change how you play:</p>
<p><strong>Hippo</strong> gets to manually place one reinforcement die each turn before the rest distribute randomly. One die placement doesn&rsquo;t sound like much until you realise you always have a frontline territory that needs it more than anywhere else. High floor, consistent.</p>
<p><strong>Hedgehog</strong> fills weakest territories first during reinforcement. Defensively solid — you never bleed out a border territory to zero while inland territories sit at cap. It doesn&rsquo;t generate more dice, but it wastes fewer.</p>
<p><strong>Fox</strong> banks a stored die every other turn and can spend it as a critical multiplier on an attack. The stored dice accumulate (up to a cap), so the power compounds if you resist using it. Two-hop flanking passive: Fox can attack across two territory hops from border territories, making it very hard to feel safely tucked away from.</p>
<p><strong>Owl</strong> has a passive two-hop attack range like Fox but for all attacks — Owl sees further. The active ability is a Dice Transfer: move dice from one of your territories to an adjacent friendly one. Lets you concentrate force without committing to an attack.</p>
<p><strong>Turtle</strong> gets 2 dice back to a neighboring territory any time it loses a defense. It&rsquo;s the only guardian that turns <em>taking damage</em> into a resource. Hard to snowball, but very hard to finish off.</p>
<p>The AI cycles through the five guardians deterministically, so in a full 8-player game you&rsquo;ll face at least one of each.</p>
<hr>
<h2 id="the-map">The map</h2>
<p>The map generator produces irregular coloured territories from a hex grid. Hexes are the visual scaffolding — what matters in gameplay is the blob they form. Internal edges within a territory are hidden; only the outer border of each cluster is drawn. The result reads like a contested piece of ground rather than a grid.</p>
<p><img alt="A freshly generated map before the first move — eight starting positions across irregular territory blobs" loading="lazy" src="/posts/dice-and-shrines/freshmap.png"></p>
<p>The generator has acceptance criteria. A proposed map is rejected if territory sizes are too uneven — a tiny starting territory with 2 hexes versus a sprawling 15-hex territory produces a wildly unfair game. The stats service actually records generation attempts and acceptance rates per map, so I can see how often the map generator throws away its own work.</p>
<p>The <code>mapId</code> is stamped on every game record, so I can eventually correlate map topology with game outcomes. I haven&rsquo;t done that analysis yet, but the data is there.</p>
<hr>
<h2 id="what-the-tools-actually-felt-like">What the tools actually felt like</h2>
<p>Claude Code handles the kind of work that benefits from holding a lot of context simultaneously: &ldquo;this change to <code>resolveAttack()</code> in <code>game.js</code> needs corresponding updates to the AI logic in <code>ai.js</code> and the render pass in <code>render.js</code>.&rdquo; That&rsquo;s tedious to track manually and exactly the kind of thing where the tool earns its keep.</p>
<p>Codex was more useful for the boilerplate-heavy parts — filling in schema.sql, wiring up FastAPI endpoints, the chart.js setup in the dashboard. Directed generation of code you know exactly how should look.</p>
<p>Neither tool replaces thinking. The guardian ability design, the shrine balance, the question of whether Fox&rsquo;s stored critical is too swingy in epic mode — that&rsquo;s all still just sitting down and working it out. The tools speed up the translation from &ldquo;I know what I want&rdquo; to &ldquo;here is working code.&rdquo; The game design itself doesn&rsquo;t compress.</p>
<hr>
<h2 id="its-live">It&rsquo;s live</h2>
<p>The game runs at <a href="https://dice.hippotion.com">dice.hippotion.com</a>. Single HTML file served from a Kubernetes ConfigMap on my homelab. No accounts, no install. It runs fast — AI turns are instant when you toggle off animations, and a full game can be over in five minutes or stretch to twenty depending on how the map falls.</p>
<p>Stats dashboard at <a href="https://game-stats.hippotion.com">game-stats.hippotion.com</a>. More on that in the next post.</p>
]]></content:encoded></item></channel></rss>