Fairway is our mini golf game. Every hole is generated, so every hole has to be proven clearable at or under its stated par before a player ever sees it.

It shipped with four verification seams: __verify, __verifyCurrent, replayWitness and __prove. Four. That is more verification than most of our games have.

Every one of them replayed the shot through runShot(), while the player's ball runs through shoot() and advance(). Nothing anywhere asserted that those two paths produce the same result.

The sabotage matrix

We took a copy of the shipped file and broke things in it, one at a time, then ran both the replay seam and a harness driving the real input path.

Sabotage Replay seam Live path
Witness angle +0.05 rad 0/9 red 0/9 red
Near-miss counts as sunk 8/9 red 2/9 red
Delete strokes++ from shoot() 9/9 green 0/9 red
Extra drag in advance(), live only 9/9 green 0/9 red

The two that stayed green are the two that matter.

Deleting the stroke increment makes the game unloseable — you can never exceed par, because your strokes never go up. Adding drag only to the live path makes par unreachable — the ball no longer travels as far as the proof assumed.

Both are catastrophic. Both were completely invisible to four verification seams, because all four verified a version of the game the player never touches.

The rule

A prover has to be driven through the code the player's input reaches, not through a helper that shares its physics constants.

Sharing the constants is what makes this so convincing. runShot() and advance() used the same gravity, the same friction, the same restitution. They agreed on everything except the parts that had drifted — and the parts that drift are, by definition, the parts nobody is checking.

The fix was a __playWitness() that plays the proof's own witness shot through the real input path and reports what the scoreboard actually said afterwards.

A sabotage staying green is not automatically a hole

Worth stating, because the opposite belief leads to chasing ghosts.

We enlarged the cup 10x. Both seams stayed green, and that was correct: the generator rejects every layout the search can ace, so a bigger cup does not produce a one-shot hole. The MIN_PAR floor was already doing that work.

We only accepted it after verifying it independently — an 864-shot lattice across the hole, 0 aces, nearest rest 3.03 units from the cup. A green sabotage is a question, not an answer.

"Proven minimum" was an overclaim

While we were in there, we found the copy was wrong in a subtler way.

A bounded search returns a proven upper bound, not a minimum. It proves the hole is clearable in N. It does not prove N is the fewest. A dense sweep — 1440 angles by 30 powers — found one-shot lines on a hole we had shipped as par 2.

The copy now says "at or under a proven par", which is exactly what the search establishes. Say what you actually proved, not the nearest impressive thing.

And a prover that runs on a budget must not leave the world half-swapped

Fairway generates in the background with a time budget. It placed the ball on the current generation attempt while the prover kept re-rolling layouts — and when it finished, it swapped in the final layout and never moved the ball.

Result: the ball standing inside a wall, on a hole it did not belong to.

We reproduced it by setting the budget to 0ms. The worst real proving time we measured was 293ms, so shipping it would have needed only a phone about three times slower than our test device. Which is to say: a phone.


Play it: Fairway · how to play

Related reading: three ways a suite passes with the rule deleted · five wrong ways to prove a racing game is fair