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.
I wrote about it at length. I shipped it. I was pleased with it.
It never appeared on screen. Not once. Not for anyone.
Structurally incapable
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.
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.
var called = HumanOnOffense ? PendingPlayCall : PendingSchemeCall;
if (called is null || !called.IsTactic) return;
called was always null. The guard always tripped. The function always returned. The panel was
built, styled, animated, tuned, and never given anything to render.
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 — "Capture pending state before ApplyPlayResult clears it" — 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.
The knowledge was in the file. It just wasn't in my head at the moment I needed it.
What the tests were doing instead
Here is what makes this worth writing about rather than just fixing quietly.
The build was green. The tests passed. And the tests were good 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.
Every one of those tests calls the grader directly. Not one of them asked whether anything ever called the grader.
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.
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 code for the grade panel, which is a completely different activity that feels identical from the inside.
The one that shipped
While we're being honest, the coaching report wasn't even the worst one.
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.
I could not reproduce it. Not once, not on any machine I had.
The cause was capitalization. The player models load from res://assets/models/... and the folder
on disk is Assets/Models. 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-sensitive. The load returns nothing. The scene instantiation throws. And it throws inside
the routine that populates the field, which runs before 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.
The reason I couldn't reproduce it is the whole lesson. I always played from the project folder,
where the loose Assets\Models\ 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
more forgiving than the shipping one, in a way that made a fatal bug invisible right up until it
reached a stranger.
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.
The panel that argued with itself
Once the report could actually draw, I got to find out what I'd designed.
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.
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.
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 contain 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.
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 call 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.
That one bothered me more than the crash. A crash is a mistake. A system confidently reporting something its own display disproves is a credibility problem — and the entire pitch of this feature is "trust me, I'll tell you whether that was skill or luck."
Saying something true
So the report names the call now. And I had to be careful about how.
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.
So the sentences are generated from the same table the resolver scores with. When the report says "a screen into their blitz — the pressure ran itself straight out of the play," that's the same 1.3 multiplier the engine applied. When it says "you ran at a light box," 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.
The situational lines work the same way, off the tags on the cards themselves: "nothing in your hand fit third-and-long better than that call." That's not flavor. That's the actual ranking the grade was computed from, said out loud.
Taking it away again
The last change to the report was removing it.
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.
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 player's 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.
Meeting people where they are has to include the people who are already there.
The guard that cried wolf
One more, because it's the one I'd have dismissed as trivial a month ago.
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.
It compared file modification times. And git checkout 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.
It hashes file contents now. It's silent on an unchanged tree and it names the exact files when something really moved.
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 feeling 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.
(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.)
Where this leaves it
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.
By the measure I care about now, it's the update where the last one actually started working.
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.
The players show up on the field, which they did not do in the build I was proud of.
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: observed working, by someone, in the form they'll actually receive it. 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.
The build folder is the game now. Not the project. That's the whole lesson, and it cost more than it should have.
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.
Turning something off until it's right is also a form of shipping.