Spoiler: this post gives away the solution to one puzzle in the game it describes.
Upstairs: The Locked Floor is our offline thriller game for iPhone and iPad: five rooms, five items you can carry, and a chain of tool, key, code and fuse puzzles. In the bedroom there is a box with a four-digit dial. On 19 September 2026, minutes after version 1.0.0 had gone to App Review, the studio's owner was playing that version, typed the right four digits into the dial, and the box stayed shut.
The puzzle itself was sound. The fault was a condition that an AI agent, which writes this game's code under our direction, had put next to it in two places. The lock took the right code only from a player who had picked up one clue and inspected another, and the save validator rejected any saved game that said otherwise. We withdrew the build, removed the condition from both places and resubmitted eleven minutes after the first submission, so the bug never reached the App Store.
What the player is meant to find
The bedroom. The box stands on the desk under the lamp, and the calendar hangs on the wall above it.
The box opens with 1903. Two objects lead there. The calendar on the bedroom wall has 19 March circled in red, with a line in a character's handwriting underneath:
Day first, then month. Four digits.
The other object is a photograph from the kitchen drawer. Its back carries the decoy and the correction together: 1936 is the year the building was built, “but the box opens with the day and month on the calendar, not the year.” The box points at the photograph too: a small photograph symbol is scratched beside the dial.
So the intended walk is photograph, calendar, dial. Look at what the calendar does without any help, though: a circled date, and an instruction for turning it into four digits. A player who leaves the photograph in the drawer can still read 19 March as 1903. The photograph only protects against typing 1936.
The hidden precondition on the lock
The owner's report was short: the code 1903 is refused. The cause was a condition attached to the dial. The correct code was accepted only if the photograph was in the bag and the calendar had been inspected.
The right code is on the dial, and the lock stays shut.
It is easy to see how a rule like that gets written, by a person as much as by the agent that wrote this one. In the designer's head the puzzle is a path: find this, read that, then enter the code. Turning the path into a precondition feels like protecting the puzzle from guesses. But a lock in a room is a physical thing: it knows which digits are on its dial, and it does not know what the person turning it has looked at.
Consider who that rule turns away. A player who solves the puzzle from the calendar alone, which the calendar's own text allows. A player on a second run who remembers the code. And a player who asks for help: the hint button is staged, and for a player who holds the photograph its last hint reads, in full, “The code is 1903. Open the box and take the fuse.”
A game that hands over the answer has promised that a stuck player may move on. A lock that also wants proof that the calendar was tapped breaks that promise. From the player's side every case looks the same: the game asked for a code, got the right one and said no. Nothing on screen explains the refusal, because the condition lives in a flag and the player cannot see a flag.
The same condition in the save validator
Taking the condition off the dial was half of the fix. Upstairs saves after every action, and a save is checked before it is loaded. The validator rejects states that cannot happen in the story. In the sample, s is the decoded state:
let has = s.flags.contains
// …
if (s.inventory.contains(.fuse) || has("powerOn")) && !has("boxOpened") {
return false
}
if has("powerOn") && s.inventory.contains(.fuse) { return false }
A fuse without an opened box does not load, and neither does a fuse that is in the bag and in the fuse box at once. That strictness is deliberate. It also makes the validator a second copy of the game's rules, and the clue condition had been copied into it: an opened box without the photograph and the inspected calendar counted as an impossible state.
Fixing only the dial would have produced a worse bug than the one reported. A player skips the clues, opens the box, takes the fuse and closes the app, and the save made on that path is refused the next time it is read. So the condition came out of both places in one change. We cannot show the condition as it was written, because this repository's history begins with a later version. The lock as it is now asks where the player is, whether the box is already open and what was typed:
// inside perform(actionID:), for an action that starts with "code:"
guard state.room == .bedroom, !state.flags.contains("boxOpened") else {
// … returns the message that the dial is in the bedroom
}
// …
guard actionID == "code:1903" else {
// … returns the message that the lock did not open
}
mark("boxOpened")
Build 3 of version 1.0.0 had gone to App Review at 14:25 Istanbul time, and the report came minutes later. We withdrew that submission and sent build 4 at 14:36, with the condition removed and the tests described below added in the same change. Build 3 never reached the store: version 1.0.0 went on sale with build 4.
The engine check: every action at every checkpoint
We could remove the condition that quickly and trust the result because of how the game is built. All of the rules live in one value type, GameEngine, in a Swift 6 file whose only import is Foundation: no views, no audio, no store code. A seven-line shell script compiles that file together with a check program and runs it on the Mac:
swiftc UstKat/GameModel.swift scripts/engine-check.swift \
-o "$build_dir/engine-check"
"$build_dir/engine-check"
The check first plays the real story from the first room to the fuse and keeps a copy of the engine after every step. It adds the states that come later in the story. Then, at every distinct checkpoint, it tries everything a player could try. actions and targets list every command and every object in the game, and check counts an assertion and stops the program if it fails:
func audit(_ e: GameEngine) {
check(GameEngine.valid(e.state), "each action preserves save invariants")
var copy = GameEngine()
check(copy.restore(data: e.encoded()!), "each action can be saved and resumed")
}
for checkpoint in checkpoints {
// … a checkpoint already seen is skipped
for action in actions {
var e = checkpoint; _ = e.perform(actionID: action); audit(e)
}
for room in RoomID.allCases {
var e = checkpoint; _ = e.travel(to: room); audit(e)
}
for target in targets {
var e = checkpoint; _ = e.interact(targetID: target); audit(e)
for item in ItemID.allCases {
var e = checkpoint
_ = e.interact(targetID: target, selectedItem: item); audit(e)
}
}
var e = checkpoint; _ = e.hint(); audit(e)
}
After each attempt the state must still pass the validator and must survive being encoded and restored. The engine is a struct, and structures in Swift are value types, so var e = checkpoint is a full copy and every branch starts from its own.
Before any of that, the check tries forged moves on a new game, such as taking the fuse or entering the code from the corridor, and none may change anything. It also feeds the engine broken JSON, a save from a future schema version and a state that starts the player upstairs, and all three must be refused.
This check already existed when the bug was reported and had passed 7,432 assertions that day. It entered 1903 at checkpoints where a clue was missing and saw nothing wrong: a lock that refuses leaves a valid state, and validity was all the audit asked about. The assertions that expected the box to open all came after the designed walk, and so did the UI tests.
We use the same split in other games: six bots play a whole market-game campaign from the command line, and a music mixer is tested without playing a sound.
The tests added for this bug
The check gained a block that asserts the outcome. ready() returns an engine that has played the real story up to the fuse, and the block takes the opened box, the fuse and, in turn, each clue back out of that state:
// The physical lock accepts the right code even if clue interactions were skipped.
for photoSeen in [false, true] {
for calendarSeen in [false, true] {
var lock = ready()
_ = lock.travel(to: .bedroom)
lock.state.flags.remove("boxOpened")
lock.state.inventory.removeAll {
$0 == .fuse || (!photoSeen && $0 == .photograph)
}
if !calendarSeen { lock.state.flags.remove("calendarSeen") }
_ = lock.perform(actionID: "code:1936")
check(!lock.state.flags.contains("boxOpened"),
"wrong code rejected regardless of clues")
_ = lock.perform(actionID: "code:1903")
check(lock.state.flags.contains("boxOpened"),
"1903 opens with any clue combination")
// … take the fuse, save, restore, switch the power on
}
}
The block runs four combinations of photograph and calendar. In each one the decoy is rejected, the right code opens the box, the fuse can be taken, the save restores and the story continues. The count went from 7,432 checks to 7,512, because each of the four runs replays the story first and every replayed step is a check as well. Compiling and running all of them takes about a second.
One UI test covers what the engine check cannot see: the keypad. In an iPad simulator it takes the key from the drawer, leaves the photograph and walks past the calendar. It taps 1, 9, 0, 3 on the on-screen dial, takes the fuse, quits the app, launches it again and looks for the open box. The rule itself went into the project's lessons file, which every agent session reads before it starts work.
All of this runs on a Mac and in the simulator. It proves that the rules hold together. It says nothing about whether a first-time player finds the calendar, and we do not read it that way.
Rules we keep for locks, saves and tests
- A lock checks the code. Clues help the player find the answer and are not part of it. If a hint gives the answer away, the lock has to take it.
- Write a test for the shortest path, the one that skips everything optional, and make it assert the outcome. An audit that only asks whether states stay valid will accept a lock that wrongly refuses.
- A strict save validator is a second copy of the rules. Change a rule and its copy in the same commit, then load a save made on the new path.
- Keep the rules in a type that compiles without the interface. Then “try everything everywhere” is a loop, and it runs before the app does.
Upstairs is on the App Store for iPhone and iPad. We build mobile apps for clients the same way, with the rules in one type that can be tested on its own and the interface on top.