<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>aerbuf devlog</title>
    <link>https://aerbuf.com/devlog/</link>
    <description>Dev diaries and patch notes for xvorogue, timquest, and fiveit.</description>
    <language>en-us</language>
    <managingEditor>aerbuf@gmail.com (M. Bufanio)</managingEditor>
    <lastBuildDate>Fri, 28 Aug 2026 12:00:00 +0000</lastBuildDate>
    <atom:link href="https://aerbuf.com/devlog/feed.xml" rel="self" type="application/rss+xml"/>
    <item>
      <title>The Feature That Never Ran</title>
      <link>https://aerbuf.com/devlog/the-feature-that-never-ran/</link>
      <guid isPermaLink="true">https://aerbuf.com/devlog/the-feature-that-never-ran/</guid>
      <pubDate>Fri, 28 Aug 2026 12:00:00 +0000</pubDate>
      <category>xvorogue</category>
      <category>Dev diary</category>
      <description>On shipping code that compiles, passes its tests, and does nothing at all.</description>
      <content:encoded><![CDATA[<p>Last update I wrote about building a door into a game made for sickos. Ball Knowledge, plain-English
card faces, a glossary on every hover, and — the part I was proudest of — a coaching report after
every snap. A letter grade, three meters, and one sentence telling you whether the play worked
because you called it well, because your roster was better, or because the dice went your way.</p>
<p>I wrote about it at length. I shipped it. I was pleased with it.</p>
<p>It never appeared on screen. Not once. Not for anyone.</p>
<hr>
<h2>Structurally incapable</h2>
<p>I want to be precise about this, because "it was buggy" would be letting myself off easy. The
coaching report was not intermittently broken. It could not draw, on any snap, under any
conditions, for any player. Every single frame of its existence was spent returning early from a
guard clause.</p>
<p>Here's the shape of it. The report grades the call you made, so it runs after the play resolves —
you want to read the grade against the result you just watched. But the function that commits a
play result also clears your pending call, because the call has been spent; it's not pending any
more. The report was reading that cleared value.</p>
<pre><code>var called = HumanOnOffense ? PendingPlayCall : PendingSchemeCall;
if (called is null || !called.IsTactic) return;</code></pre>
<p><code>called</code> was always null. The guard always tripped. The function always returned. The panel was
built, styled, animated, tuned, and never given anything to render.</p>
<p>The part that stings: two lines above the call site, the code already knew. There's a comment
sitting right there in the resolution path — <em>"Capture pending state before ApplyPlayResult clears
it"</em> — and a set of local variables doing exactly that, feeding the play-by-play log. Somebody
(me) had hit this exact hazard before, understood it, wrote it down, solved it, and then wired a
new feature straight past the solution.</p>
<p>The knowledge was in the file. It just wasn't in my head at the moment I needed it.</p>
<hr>
<h2>What the tests were doing instead</h2>
<p>Here is what makes this worth writing about rather than just fixing quietly.</p>
<p>The build was green. The tests passed. And the tests were <em>good</em> tests — the coaching report has a
suite that pins genuinely subtle properties. That the grade never reads the play's outcome, so it
can't secretly become a scoreboard. That the bands are quantized into five states so repeated
observation can't be inverted into a probability table. That no number or percentage ever leaks
into the text.</p>
<p>Every one of those tests calls the grader directly. Not one of them asked whether anything ever
called the grader.</p>
<p>That's the gap, and it isn't a gap you can close by writing more of the same tests. I had thorough
coverage of a component and zero coverage of its wiring. The component was flawless. It was also
unreachable, which made its flawlessness worth precisely nothing.</p>
<p>I don't have a clean answer for this. The honest version is: unit tests verify that a thing works,
and nothing I own verifies that the thing runs. In a game, the only real integration test is
playing it, and I hadn't played a snap and consciously looked for the grade panel — I'd looked at
the <em>code</em> for the grade panel, which is a completely different activity that feels identical from
the inside.</p>
<hr>
<h2>The one that shipped</h2>
<p>While we're being honest, the coaching report wasn't even the worst one.</p>
<p>Update 1 failed Steam review three times. Same report each time: no cards, no players, unable to
progress. Field renders, scoreboard renders, menus work. Just an empty stadium and no way to play.</p>
<p>I could not reproduce it. Not once, not on any machine I had.</p>
<p>The cause was capitalization. The player models load from <code>res://assets/models/...</code> and the folder
on disk is <code>Assets/Models</code>. On Windows that's the same path — the filesystem doesn't care about
case. But once the game is packed into its distributable archive, lookups inside that archive are
case-<strong>sensitive</strong>. The load returns nothing. The scene instantiation throws. And it throws inside
the routine that populates the field, which runs <em>before</em> the cards are dealt — so everything built
earlier survives and everything after it never happens. Which is exactly, precisely the symptom
three different reviewers wrote down.</p>
<p>The reason I couldn't reproduce it is the whole lesson. I always played from the project folder,
where the loose <code>Assets\Models\</code> directory still sits next to the executable, so Godot's filesystem
fallback quietly resolved my wrong-case path against the real one. My development environment was
<em>more forgiving</em> than the shipping one, in a way that made a fatal bug invisible right up until it
reached a stranger.</p>
<p>I now smoke-test the staged build folder, not the project folder. It cost me three review cycles to
learn a rule that takes one sentence to state.</p>
<hr>
<h2>The panel that argued with itself</h2>
<p>Once the report could actually draw, I got to find out what I'd designed.</p>
<p>The first thing it did was appear for about a second and a half in the middle of the screen, over
the field, while the touchdown banner and the yardage numbers fired over the same real estate. The
grade is three meters and a sentence. You cannot read three meters and a sentence in a second and a
half while a "+42 YDS" is exploding across it.</p>
<p>That's a design error I could only have found by looking at it, and it's a good argument for
shipping the ugly version early. I'd tuned the animation curve on that popup. Punch-in scale,
settle, hold, drift up and fade. I had opinions about the easing. All of that was work spent on a
thing whose fundamental problem was that it was temporary and in the wrong place.</p>
<p>Then the banners. It turned out the touchdown and first-down banners opened at 12 pixels below the
scoreboard with a 150-pixel-tall zone, and the last-play strip lives at 6 to 44 pixels below the
scoreboard. The banners didn't overlap the strip so much as <em>contain</em> it — they started six pixels
inside it and ran a hundred and eighteen pixels past its bottom edge. Every touchdown buried the
thing telling you what just happened.</p>
<p>And then the sentence itself. When the two rosters were even and neither scheme had an edge, the
report said "this one came down to the dice." Reasonable. Except it decided that by looking only at
the roster and scheme meters and never at the <em>call</em> meter — so on a snap where you'd clearly
out-called the opponent, the panel showed you a full green CALL bar and a sentence telling you the
play was random. It was contradicting itself on screen, in two elements six pixels apart.</p>
<p>That one bothered me more than the crash. A crash is a mistake. A system confidently reporting
something its own display disproves is a <em>credibility</em> problem — and the entire pitch of this
feature is "trust me, I'll tell you whether that was skill or luck."</p>
<hr>
<h2>Saying something true</h2>
<p>So the report names the call now. And I had to be careful about how.</p>
<p>It would be easy to write forty lines of football-sounding flavor. "Screens beat blitzes." "Slants
are good on the goal line." Some of it would even be right. But this game has a simulation engine
underneath it that has actual opinions about which concepts profit against which shells, expressed
as a table of numbers, and there is nothing worse than a game that explains itself in a way its own
rules contradict. Players notice. They should notice; noticing is the game.</p>
<p>So the sentences are generated from the same table the resolver scores with. When the report says
<em>"a screen into their blitz — the pressure ran itself straight out of the play,"</em> that's the same
1.3 multiplier the engine applied. When it says <em>"you ran at a light box,"</em> that's the 1.4 the
engine applied for a run against a dime front. If I retune those numbers tomorrow, the sentences
follow, because they read the same source. The report cannot tell you something the game
disagreed with — not because I was careful, but because there's only one place the answer lives.</p>
<p>The situational lines work the same way, off the tags on the cards themselves: <em>"nothing in your
hand fit third-and-long better than that call."</em> That's not flavor. That's the actual ranking the
grade was computed from, said out loud.</p>
<hr>
<h2>Taking it away again</h2>
<p>The last change to the report was removing it.</p>
<p>At the highest fluency setting — Certified Ball Knower, the mode for people who see "P22" and
picture the box count — the explanation sentence doesn't appear. You get the letter and the three
meters and nothing else.</p>
<p>This is the same call I made last update with the TIP line on the cards, and I think it's the most
important design principle in the whole game: reading the situation is the <em>player's</em> skill. The
door I built for newcomers cannot become a set of training wheels bolted onto the expert
experience. An expert doesn't need to be told the matchup was even and the schemes cancelled — they
can see both meters. Telling them anyway is noise, and worse, it does the interesting part of the
thinking for them.</p>
<p>Meeting people where they are has to include the people who are already there.</p>
<hr>
<h2>The guard that cried wolf</h2>
<p>One more, because it's the one I'd have dismissed as trivial a month ago.</p>
<p>There's a script that uploads builds to Steam, and it has a guard: if the source has changed since
the last packaged export, warn me, because otherwise I'll upload a stale build. Good guard. It
saved a stale build from shipping at least once.</p>
<p>It compared file modification times. And <code>git checkout</code> rewrites modification times without
changing a single byte, so switching branches made it fire on a build that was demonstrably correct.
It cried wolf, and I typed "yes" past it, and typing "yes" past a safety check is a habit you only
have to form once.</p>
<p>It hashes file contents now. It's silent on an unchanged tree and it names the exact files when
something really moved.</p>
<p>I mention it because a guard you've learned to ignore is worse than no guard at all. No guard means
you stay nervous. A guard you dismiss reflexively gives you the <em>feeling</em> of safety while
functioning as a speed bump. That's how a stale build reaches review — not because nothing was
watching, but because the thing watching had taught me it was usually wrong.</p>
<p>(It caught something real while I was writing these very notes, incidentally. A stash-and-restore
had rewritten a source file's line endings, so its content genuinely differed from what the last
package was built from. Behaviorally identical, completely harmless — and I re-exported anyway,
because the entire value of the guard is that I don't get to decide which of its warnings are the
boring kind.)</p>
<hr>
<h2>Where this leaves it</h2>
<p>This patch adds almost nothing. One new sentence generator, some tooltip styling, a panel that used
to be two panels. By the measure I'd have used a year ago it's a nothing update.</p>
<p>By the measure I care about now, it's the update where the last one actually started working.</p>
<p>The coaching report exists. It sits in the corner in a panel that waits as long as you need instead
of flashing past in a second and a half. It tells you what decided the snap and it can't lie to you
about it, because it reads the same numbers the engine does. It shuts up at the highest difficulty
because that's the point.</p>
<p>The players show up on the field, which they did not do in the build I was proud of.</p>
<p>I've been writing software long enough to know the difference between "done" and "shipped," and I
keep relearning that there's a third state past both of them: <em>observed working, by someone, in the
form they'll actually receive it.</em> Everything short of that is a hypothesis. My tests were a
hypothesis. My playtests from the project folder were a hypothesis. Three review rejections and a
feature that never drew a single pixel are what a falsified hypothesis looks like.</p>
<p>The build folder is the game now. Not the project. That's the whole lesson, and it cost more than
it should have.</p>
<p>Next up: the crowd bed, which is currently disabled because it sounds less like eighty thousand
people and more like an untuned radio. I know why. It's a synthesis problem, not a mixing problem,
and it deserves its own pass rather than one more attempt to EQ my way out of it.</p>
<p>Turning something off until it's right is also a form of shipping.</p>]]></content:encoded>
    </item>
    <item>
      <title>xvorogue is out</title>
      <link>https://aerbuf.com/devlog/xvorogue-launch-day/</link>
      <guid isPermaLink="true">https://aerbuf.com/devlog/xvorogue-launch-day/</guid>
      <pubDate>Thu, 13 Aug 2026 12:00:00 +0000</pubDate>
      <category>xvorogue</category>
      <category>Dev diary</category>
      <description>xvorogue ships today, and this is what the game is, how you actually win a run, the bug I had to fix twice, and the parts I need players to break.</description>
      <content:encoded><![CDATA[<p><a href="https://store.steampowered.com/app/4953970/xvorogue/" target="_blank" rel="noopener">xvorogue is on Steam</a> as of today. This is the launch-day diary: what the game is, what is underneath it, one thing I got badly wrong, what is missing, and where your bug reports will actually do some good.</p>
<h2>What this is</h2>
<p>xvorogue is a roguelike deckbuilder built on football. You pick a team, draft a roster and a playbook, and coach a season under a contract. Win and you push the same build deeper. Lose enough and the franchise fires you mid-season, and that is the run.</p>
<p>It is not a management sim and it is not Madden. You do not move a stick, and you do not set a depth chart forty players deep. On every snap you call a play from a hand of cards, pick the player you want featured, and optionally spend a trick. Then the simulation settles the down and tells you what happened. The decision is your job. The execution is not.</p>
<p>The shape of a run: 32 teams, each with its own legacy that rewards a different style. 154 cards across tactics and tricks. A seven-round draft, a weekly waiver wire, a coaching skill tree, and a handful of mid-week actions you never have enough of. Contracts of one, four, or seven seasons, or infinite. Nine-game or seventeen-game seasons. Quarters of four, six, or eight minutes. Five difficulties from Rookie to GOAT, and GOAT stays locked until you win a run on Elite.</p>
<h2>How you actually win a run</h2>
<p>There is exactly one way, and it is worth stating plainly because it changes how you should plan.</p>
<p><strong>You win a run by winning the Omega Bowl in the final season of your contract.</strong> Nothing else closes a run out in the black. Sign for seven seasons and the only result that counts as a win is a ring in season seven.</p>
<p>Titles you win before then are not nothing, and I want to be careful not to undersell them. A championship season is the largest single swing in job security the game offers, which is frequently the difference between reaching your final season and getting fired in year four. It stacks in your run score. It unlocks the legacy of the team you beat. A run with three interim titles is in a far better position than a run with none.</p>
<p>But it is position, not victory. Win the Omega Bowl in season two of a seven-season deal and the confetti falls, the season summary comes up, and then you go back to work with five years left on the contract. If season seven ends without a ring, the run is recorded as a contract that expired, however many banners are hanging. Interim championships buy you the runway to win the run. They do not win it for you.</p>
<p>On an infinite contract there is no final season, so there is no win condition at all. You coach until the franchise fires you, and the only question is how long and how high.</p>
<h2>The simulation is the reason the game exists</h2>
<p>Every snap is settled by our football simulation, built on more than 18 trillion simulated plays and 56 years of real NFL data: actual player ratings, formations, and play-calling. It is not a dice roll with a football skin on it.</p>
<p>The practical consequence is the thing I care about most. Football knowledge transfers. Personnel packages matter. Down and distance matter. Calling 22 personnel on third and two against a dime front is a good idea in xvorogue for exactly the reason it is a good idea on a Sunday, and the sim will pay you for noticing. If you know the sport, you already know most of how to play this.</p>
<p>Here is the honest cost, and you should know it before you buy. The sim is right on average and completely indifferent to your feelings in the moment. If you engineer a 78 percent look, it will fail roughly one time in four, and it does not know or care that the one time was the fourth quarter of your final-season Omega Bowl. There is no rubber band that notices you are having a bad night and quietly helps. Some players are going to read that as the game being unfair. It is the price of any of the results meaning anything, and I decided to pay it.</p>
<p>One related note: if the main menu says <code>SIM ENGINE (native engine not found)</code>, the full simulation did not load on your machine and you are running the lightweight built-in fallback, which resolves plays differently. That is a bug report, and a high-priority one.</p>
<h2>The play that was never thrown</h2>
<p>The progression fantasy in xvorogue is super soldiers. Stats are deliberately uncapped above 99. You concentrate practice, card evolution, and legacy bonuses onto two or three players until they tower over everyone on the field while the rest of your roster stays ordinary on purpose. Focus is how you cash that in: you pick a play, then you pick the player who should get the ball.</p>
<p>Building a monster is worthless if you cannot get him the ball. Focus is not a convenience feature, it is the spine of the entire progression loop.</p>
<p>The game layer and the simulation talk across a narrow contract that I designed, and I had never verified that the sim honored the half of that contract focus depends on. On 13 June I stopped reasoning about it and drove the shipped simulation directly with controlled requests, 400 to 500 iterations per configuration, because the sim has its own randomness and a single run tells you nothing. The test was a mesh concept in 11 personnel with three receivers given identical stats.</p>
<p>Baseline, no overrides: 100 percent of completions went to the receiver in slot 0. I gave receiver 2 guaranteed separation, the override whose entire job is to get him open. 100 percent to slot 0. I blocked slot 0 with the override whose entire job is to forbid completions to him. 100 percent to slot 0. I blocked every receiver except number 2 and force-fed number 2 as hard as the request format allows. 100 percent to slot 0. I made receiver 2 statistically the best player on the field, and then the worst. Slot 0, both times.</p>
<p>The sim resolves each concept around a fixed slot, always. Every override the focus feature was built on did nothing whatsoever.</p>
<p>Two details made it worse. The separation override works by setting the stat to 99, so on a player already above 99 it is a downgrade: a receiver with a 170 route-running rating averaged 3.37 yards without it and 3.27 with it. I had shipped a button that made your best player slightly worse.</p>
<p>Then I replayed the last request a real session had actually produced, 400 times. It was a play-action card. The game layer treated it as a pass, attached focus boosts to a tight end, and then sent it to be resolved as a counter run. The ball went to the running back on every single snap. The focused tight end touched it zero times, and the box score said he caught it, because my own resolver renamed the ball carrier after the fact.</p>
<p>That last part is the piece I got wrong twice. The first mistake was trusting a mechanism I had never tested end to end. The second was worse: when the attribution looked wrong, I wrote a post-hoc relabel so the box score would agree with the player's intent. It made the feature look like it worked. Your monster was never touching the ball, the stat line was fiction, and my own cosmetic patch is what kept me from finding out for months. The lesson I would pay money to have learned earlier is that a display fix layered over a suspicious result is not a fix, it is a blindfold.</p>
<p>What shipped is embarrassingly simple. Before the roster is handed to the sim, the focused player is swapped into slot 0, so the player it resolves around is the player you picked. Attribution then names him honestly, because now he really did carry it.</p>
<p>The honest limit: aggregate pass concepts such as mesh resolve around the whole route combination rather than one receiver, so focus on those plays narrows your options rather than guaranteeing a target. That is how the sim reads those concepts today and I have not beaten it yet.</p>
<h2>The second time the same fantasy broke</h2>
<p>Fixing targeting immediately exposed the next layer. A 140-overall team was resolving like a 99 team.</p>
<p>The response curves flattened out near 99: a cap on big plays, completion probability clamped at 0.90, a floor under sacks, no interception term on the offensive side at all, and the outcome tree's upper stages missing outright. First Contact, Second Level, and Breakaway, the three stages where a genuinely fast player turns a six-yard gain into a long one, were not in the build. You could stack a back to 140 and there was no stage left to produce the yards.</p>
<p>The Dominance Pass restores those stages after resolution, so it behaves the same way whether the full sim or the fallback produced the result. It is dead-zoned: matchups in the ordinary 65 to 95 band resolve byte for byte identically to before, so it only fires where the old curves were lying. 1,277 tests green when it landed.</p>
<p>If your 140-overall running back breaks a long one now, that is not a bug. This is the first build where it was possible.</p>
<h2>What is not in there</h2>
<ul>
<li><strong>Windows only at launch.</strong> Other platforms are a question for after I see how this one goes.</li>
<li><strong>Injuries are cut.</strong> The toggle is hidden in the run setup screen and the plumbing behind it is still wired, so it can return. I would rather ship without them than ship a system that deletes the super soldier you spent six weeks building on a coin flip. It comes back when I am confident it makes runs more interesting rather than just more annoying.</li>
<li><strong>No onside kicks and no two-point conversions.</strong> A touchdown is seven points and the extra point is automatic. Both are on the list.</li>
<li><strong>No multiplayer.</strong> The architecture allows for it. That is not the same as it existing.</li>
<li><strong>154 cards against roughly 190 planned.</strong> Several team identities are noticeably thin.</li>
<li><strong>Team balance is uneven, and I know which direction.</strong> 19 of the 32 teams use a parameterized roster generator tuned into a tight 72 to 79 band. The other 13 were built by hand, and several of those are materially stronger. I have not decided whether to tighten the 13 or accept that the signature teams are simply better, and I would rather hear from players before I pick.</li>
</ul>
<h2>What happens next</h2>
<p>Patch notes for every update land on this devlog. Dev diaries when there is something real to say, not on a schedule. The first patch will be fast, because launch day always finds things no amount of solo testing does, and I would rather ship a small fix in days than a tidy one in a month. There is an RSS feed at <a href="/devlog/feed.xml">/devlog/feed.xml</a>.</p>
<h2>The Discord</h2>
<p><a href="https://discord.gg/PPvXdCzWWZ" target="_blank" rel="noopener">discord.gg/PPvXdCzWWZ</a> is where the fast loop happens. Post a repro there and there is a real chance it is fixed before I have finished writing it down, and it is the best place to argue with me about balance.</p>
<p>Use the <a href="/#contact">contact form</a> for anything that does not belong in a public channel: purchase problems, or a save file you would rather not post where everyone can see it.</p>
<h2>What I need you to break</h2>
<p>This is the part I actually care about on day one. In priority order, because scattered reports help less than aimed ones.</p>
<p><strong>1. The first hour on a fresh profile.</strong> This is where my testing is thinnest by a wide margin, and the reason is boring: I have been playing the same save for months and I already know what every screen means. I cannot see this game the way you will see it. If the tutorial skips something load-bearing, if a phase begins and you cannot tell what the game wants from you, if you lost a down because you did not know a timer was running, that is a real bug and I want it reported like one.</p>
<p><strong>2. Balance across the 32 team legacies.</strong> I have a spreadsheet saying they are close. A spreadsheet is not a hundred real games, and it never has been. If a legacy feels like a free win, or like an empty slot you forget you have, that is the single most useful report you can send me right now.</p>
<p><strong>3. Infinite mode at depth.</strong> I have tested it and I have not lived in it. Season 8, season 15, season 30. Since an infinite run has no win condition and ends only when you are fired, the whole mode rests on the late seasons staying interesting, and that is precisely the part I have the least real evidence about.</p>
<p><strong>4. Anything where the sim result feels wrong rather than merely painful.</strong> This distinction matters more than any other on this list, so let me be precise. Painful is your 78 percent look failing at the worst possible moment, or a fumble that ends a perfect season. That is the sim doing its job, and I am not going to change it. Wrong is a result that could not have happened: a player who was not on the field, a stat line that contradicts what the animation showed, yardage inconsistent with the down and distance, a punt outcome with no physical sense to it. Painful means the game is working. Wrong means I have a bug. Report the second kind loudly, and do not soften it because you are not certain which one you saw. I would rather sort through fifty painful reports to find one wrong one.</p>
<p><strong>5. Anything that ends a run for the wrong reason.</strong> A crash, a save that does not resume where you left it, a down that gets eaten. Pressing Escape while a play is mid-resolve is a genuinely untested edge. Losing a seven-season run to a software fault rather than to a bad fourth-down call is the worst thing this game can do to you, so those reports jump the queue.</p>
<p>A report I can act on the same day looks like this:</p>
<div class="table-wrap"><table>
<thead><tr>
<th>Field</th>
<th>Example</th>
</tr></thead>
<tbody><tr><td>Down and distance</td><td>3rd and 4</td></tr><tr><td>Field position</td><td>own 38</td></tr><tr><td>Card played</td><td>Counter, 12 personnel</td></tr><tr><td>Focused player</td><td>RB2</td></tr><tr><td>Legacy in use</td><td>Iron Curtain</td></tr><tr><td>Team, difficulty, contract</td><td>Blacksmiths, All-Pro, 7 seasons</td></tr><tr><td>Season and week</td><td>Season 3, week 11</td></tr><tr><td>Engine banner</td><td>did the main menu say SIM ENGINE?</td></tr></tbody>
</table></div>
<p>That last line matters more than it looks. If the full simulation did not load, everything else you saw came from the fallback, and I need to know that before I chase the wrong bug.</p>
<p>Break it and tell me what broke.</p>
<p>— aerbuf</p>]]></content:encoded>
    </item>
  </channel>
</rss>
