For a while our analytics reported that every ad impression came from AdMob.

That was true. It was also a hardcoded string. AdMob was the only network we had turned on, so the field was correct on the day it was written — and it would have gone on looking correct forever. The day we switched on mediation, every impression from every other network would still have been labelled admob, with no error anywhere, and the first thing we’d have noticed is a revenue report that quietly made no sense.

Four stacks - ads, analytics, attribution, notifications - each split by the same interface line into a policy half and a provider half
The same cut, four times. The top half knows when something belongs; the bottom half knows how to do it.

The fix was small. The network name is now derived from the mediation adapter’s own class name — we take the package leaf, so com.google.ads.mediation.applovin.AppLovinMediationAdapter becomes applovin. No lookup table, which means a network we add three years from now maps itself.

The bug is not the interesting part. The interesting part is that it was findable at all, and that’s a boundary question.

One cut, repeated

There is exactly one rule in the ad stack and it fits in two lines:

Core/Ads knows when an ad belongs. It knows nothing about AdMob. → Framework/Ads knows how to show an ad. It knows nothing about the game.

The line between them is a single interface: initialize, load, show, and an event when an impression pays. Everything above it — pacing, cooldowns, the day-zero ramp, the rules about what a run has to feel like before an ad is allowed — is written against that interface and has never seen a Google class.

The practical consequence is boring and that’s the point. Moving to a different mediation stack later is one new adapter class and one entry in an enum. Retry, backoff, the revenue ledger and every hook in the game come along unchanged.

We then made the same cut three more times, because once you’ve drawn it once it’s cheaper to keep drawing it than to argue about each case:

StackPolicy half decidesProvider half talks to
Adswhen an ad belongsAdMob
Analyticswhat an event meansFirebase, GameAnalytics
Attributionwhat counts as revenueTenjin, Meta
Notificationswhat is worth sayingAndroid, iOS, Editor

Each provider half has an editor implementation that logs instead of doing anything, which is why all four are testable without a phone in your hand.

The seam we didn’t plan for

The one that paid best wasn’t in the framework at all.

Tower Blast! has a system that chooses which piece you get next — it simulates every legal placement of every shape before it hands one over. That system needs to read the tower: how wide it is, how tall, which cells are occupied, which are special.

For a while it read all of that off the live scene, which meant it was chained to actual objects with actual renderers and actual animations in flight. So we pulled the four questions it asks into a tiny interface and had the tower answer them.

That is the whole change. Four methods.

What it bought: the real difficulty system — the same class, with the same values copied off the open scene — can now run against a board that only exists in memory. No GameObjects, no rendering, no tweens, about four milliseconds per simulated move. Which is how we ended up with twenty bots playing 6,722 moves and a table of everything the system actually does, instead of an opinion about what it probably does.

We did not write that interface in order to build a simulator. We wrote it because the coupling was annoying. The simulator was possible three weeks later because the coupling was gone.

Purity has a price, and we wrote the price down

The follow camera takes all of its state from outside. It has no reference to the tower and no reference to the game — you hand it a radius, a floor and a ceiling, and it frames them.

That’s clean. It also means the camera has no opinion about a bad ceiling, and a bad ceiling looks exactly like a camera bug. Feed it a ceiling of 1 instead of 8 and it dutifully computes a position half the standoff distance away, on top of a tower whose blocks already reach further out than that. The report you get is “the camera dived into the board.”

CeilingCamera position
8y 6.94, z −11.68
7y 6.22, z −10.94
6y 5.49, z −10.20
1y 1.86, z −6.52

There is a note in our docs directly under that table telling whoever reads it next to suspect the height limit before suspecting the camera. That note is the actual deliverable. A boundary you can’t debug across is a boundary you’ll route around at 2am.

Pure functions, and a month in one second

The re-engagement scheduler decides which notifications to line up when you close the app. It’s written as a plain function: give it the configuration, the current state and the time, and it returns a list. It holds nothing itself.

Every time the app goes to the background it cancels every pending notification and rebuilds the whole ladder from scratch. That sounds wasteful and it is — about seven cheap calls. In exchange, every daylight-saving, timezone and clock-change edge case self-heals for free, and, because the decision is a pure function, the entire ladder can be inspected in the editor without a device.

We ran a simulated month of it. It found three bugs:

→ an adaptive gap that crossed midnight turned a 32-hour nudge into a 49-hour one → the minimum-spacing rule pushed a notification off the player’s habitual hour instead of onto it → the opt-in prompt was built on an editor-only API and would have returned null in a real build

None of those would have shown up in a code review. All three were obvious the moment the thing could be run.

What it actually returned

Three seams, three things we could not otherwise have done: swap the ad network without touching a line of game code, run the difficulty system without running the game, and replay a month of notification scheduling in about a second.

That’s the return so far. If a boundary hasn’t bought us something on that list, it’s decoration, and for a team of two decoration is expensive.