#1 Improv, #2 Worldview Timeline, #3 Camera Map, #4 Morse, #5 AIS Watch, article Finding pilots without emitting RF -- Combat Search and Rescue (CSAR) RF flashlight from space, on X Watch Agadez grow (be built), then handed back, to now. Northern Niger. Keep on eye on it!

Marshall Frith

Projects

AeroBusiness, and the Bugs My Tests Were Too Polite to Find

A turn-based airline tycoon in one file, no account and no server. Also a catalogue of the bugs that only appear when you stop testing and start clicking.

Found an airline in 1970, 2000 or today. Buy aircraft that actually existed in that decade, open routes, set fares against three AI rivals, and try to still be solvent in 2050.

Play it in your browser — one file, no install, no account, no server. It saves to your own machine, which also means clearing site data clears your airline.

AeroBusiness on the setup screen — pick a decade, a hub and a brand, then take off.

The constraint that shaped it

One file. No install, no account, no network call at any point. 483 KB, and everything the game draws — the world map, thirty-seven aircraft, the executives' faces, the spinning globe between quarters — is drawn by the page itself.

That is not minimalism for its own sake. It means the same file is the web version, the iOS app's bundled payload and the thing you are about to click, with no chance of the three drifting apart.

Frequency was strictly dominant, and I had to measure it to see it

The interesting decision in an airline game is gauge versus frequency: one widebody a day, or four narrowbodies. For a while there was no decision. Four small jets beat one widebody by $2M a quarter while paying only $319K more in landing fees. Nobody would ever buy a widebody again.

The fix was not a nerf, it was a shape. Business travellers care about departure times and leisure travellers do not, so frequency pays a 0.22 exponent on business demand and 0.04 on economy. Now frequency wins short business routes, gauge wins long leisure ones, and neither answer is free.

What broke

Three bugs, and all three have the same shape: the tests passed.

The advisors stopped talking and nothing failed. Twelve advisor rules I added across three rounds referred to the player as air. The function calls it p. That ReferenceError killed the executive briefing, and with it the quarterly report and the globe fact behind it. It had been broken for weeks. Every test I had either called simulateQuarter() directly or wiped the modal root first, so none of them ever walked the path a player walks.

Two functions with the same name, and the wrong one won. I rewrote the aircraft artwork and left the old aircraftSvg defined further down the file. Hoisting meant the old definition won at runtime, while the contact sheet I was grading the art on called the new one. I was reviewing artwork the game never drew.

You could not name your airline. #start was a flex container with align-items: center and a card 5,063 px tall. Centring a card taller than the viewport puts its top above the scroll origin, where no scrollbar can reach it. The fix is one keyword — align-items: safe center — and I only found it because someone tried to start a game instead of setting .value from the console, which is what I had been doing.

Every bug that reached a player was one my tests were structurally incapable of seeing, because they called the function instead of clicking the button.

The thing I wish I had known before starting: a green test suite tells you the functions work. It says nothing at all about whether the game does.

Hosting it here cost one attribute name

This site's Content Security Policy has no unsafe-inline in style-src. The game emits 190 style="..." attributes from innerHTML, and 37 of them are built from expressions like width:'+pct.toFixed(1)+'%. Those cannot be hashed and they cannot be turned into classes.

The three options were: widen the policy for the whole site, host the game on a second origin forever, or stop them being inline styles. The third one is nearly free, because CSP guards inline style that arrives as markup — it does not guard element.style.cssText = ... from script that already passed script-src 'self'. So the build step renames every style=" to data-cs=", and a MutationObserver copies it onto the element through the CSSOM. The policy did not change by one character.

Then it logged 31 violations a quarter anyway. The static check I had written looked for setAttribute('style') and found none, because the offender was an SVG helper looping for (var k in attrs) over a computed key, with six callers quietly passing a style one. Fixing the loop fixed all six. The check now refuses to build on any computed-key setAttribute until the line has been read and acknowledged by hand, which is the only thing that would have caught it.

Looking for the code?

It is not published. These are personal builds, and what is here is the write-up rather than the repository.

The browser version is here, running the same work on your own machine: try it.

If you want to read the source or run the full desktop tool, ask. Say what you are doing with it and I will usually send it over.

Ask for the code

Comments

Plain text only. Held for review before it appears.