Case study 04

Grandma's Lighthouse

Solo, design, code, integration, release · Godot 4.3, GDScript · Shipped, playable in browser + Windows · modsy.itch.io/grandmas-lighthouse

A color-restoration Sokoban puzzle: push objects onto their home tiles and the room comes back into color, one commitment at a time.

No fail state, except the one hiding inside that rule

The design has one hard constraint: no fail state. Nothing chases you, nothing is timed, you cannot lose. A second mechanic contradicts it: when an object reaches its home tile it's committed, it blocks like a wall and undo will not take it back. Rewind past the move that placed it and the player and all unplaced objects roll back while that one stays home. You can take back your steps, you cannot take back the warmth.

Making one class of move permanent introduces the possibility of a stranded room, an arrangement from which no sequence of moves and no amount of undo can reach a solution, a fail state wearing a disguise. It would be silent: no message, no death, just a dead room.

The centerpiece: rooms aren't playtested, they're proved

The rooms aren't playtested for solvability, they're proved, in a headless test that runs on every change (tests/puzzle_model.gd). It reimplements the room rules on plain data: border walls, fixed furniture, one-tile pushes with no pulling, gates that latch open when the right object rests on the right cell. Each arrangement packs into a single integer (coordinates are 0-7, three bits each, player in bits 0-5, each pushable in the next six, each gate one bit above), so a state becomes a dictionary key with no allocation, which is what makes a millions-of-nodes search fast enough to run constantly. Breadth-first search, solved states absorbing, the first solved state found is also the shortest solution, which doubles as the room's difficulty measure. Across the five shipped rooms that shortest solution runs from 7 moves (Entryway) to 49 (Her Study), with the Kitchen at 36 and the Lamp Room at 34, and the search touches between 329 and 639,681 distinct arrangements per room, median 52,369.

What that check prints for the Entryway, the smallest room:

$ godot --headless --path . --script res://tests/verify_entryway.gd

[ENTRYWAY]
  object count           : 2
  optimal solve length   : 7 moves
  re-handling required   : no — every object is handled in one pass
  reachable arrangements : 1783
  deepest arrangement    : 28 moves from start
  dead ends (fairness)   : 1062 of 1780 non-solved  (718 still solvable)
  safe explore window    : 6 move(s) — shallowest dead end that far in
  shortest path into it  : SSEEEE
  ok   a solution exists from the start state
  ok   replaying that solution lands every object home
  ok   reset restores a solvable state (reset returns to the start)
  ok   undo buffer (256) covers the deepest arrangement (28)
  ok   no placement strands the room: all 4 reachable placements stay
       solvable from the start layout — undo always recovers
  ok   the open exit is reachable from the player in every solved state
ENTRYWAY PUZZLE TEST: PASS — solvable, undo/reset always recover

Real output from the headless run; the other four rooms print the same shape with bigger numbers.

It asserts two things and deliberately not a third: a solution exists from the start; undo and reset always land somewhere solvable (the hard one, every set of committed objects the player can reach must itself leave the room solvable from the start layout with those pre-placed, the analyzer enumerates those placements and checks each); not dead ends, wedging an object that isn't yet home is fair, undo takes it back, the player loses time, not the room. They're counted and reported, never failed on. The raw dead-end count is useless as a design signal because it explodes combinatorially the moment one crate is wedged, so first_dead_end, moves from the start to the shallowest dead end, is a fairness reading instead: how far a player explores before a wrong move costs them a rewind.

Honest limits

The search caps at 2,000,000 distinct arrangements. As the rooms are tuned today the biggest is the Lamp Room at 639,681 arrangements explored, comfortably inside the cap, but a denser 5-6 object room with an 80-move solution would blow past it, and the report is built for that: solved states sit shallow so solvability, the shortest solution, and per-object metrics stay exact; max_depth and dead-end counts degrade to lower bounds, and the report says so and downgrades the undo-buffer check to a warning rather than printing a number it can't stand behind. The undo history holds 256 moves, set above the deepest arrangement measured (145, in the Lamp Room). The whole five-room proof runs headless in about 35 seconds, five engine boots and all.

Color as an engineering problem, not an art one

Grey means unfinished, color means done, so if an object can't be told from its floor the puzzle is unreadable, a palette authority file (palette.gd) enforces minimum luma gaps using Rec. 601 weighting: a floor and any pushable object on it must sit at least 0.24 apart in luma, with tighter gaps for furniture against walls and gates against floors. Two findings came from measuring rather than looking: Godot's modulate multiplies, so it can only ever darken, a grayscale floor at 0.515 tinted with a 0.773-luma warm lands at 0.398, below both inputs, and all 14 objects were failing the legibility gate on all six floors, fixed by lifting the grayscale plates so the product landed where the ladder expected. And objects looked wrong when floors were tiled, measuring found the tile border pixels reading 103 against an interior of 144, each tile shipped with its own drop shadow, printing a grid of dark seams the palette system couldn't see, the tiles had to be de-framed before the value ladder meant anything. With that tuning done, the shipped palette now clears every one of the 95 floor-object pairings the gate checks (19 objects across 5 rooms), the tightest by just 0.04 luma.

The Kitchen room part-solved: the chair and the tin canister sit on their home tiles in full colour while the kettle, the two cabinets, the cup and the side table are still grey, with two empty target markers waiting. Both states are on screen at once, which is what the luma gate has to keep legible.
The Kitchen mid-solve: two objects home and back in colour, the rest still grey on the same floor. This is the exact frame the palette gate exists for — every grey object still has to read against the boards, and every restored one against its neighbours.

What shipped

Five rooms chaining door to door (Entryway, Half-Landing, Kitchen, Her Study, Lamp Room), a closing cinematic, a title screen that reflects a finished save, a pause menu, sound synthesized in code with no audio files. 26 headless verification suites. Web build (threaded, for browser audio) at 14 MB zipped, 40 MB unpacked with the Godot wasm runtime accounting for 33 of it, and a Windows build at 35 MB zipped.

The Entryway room as the player finds it: every object and the floor drained to grey, with pale outlined squares marking the target tiles each object has to reach.
Unrestored
The same Entryway room once solved: every object home and back in full colour, the target markers gone, the exit door lit and open.
Restored

Art

The character and object sprites are mine. The illustrated lighthouse exterior, used on the title screen and in the closing cinematic, is AI generated, and the itch.io page is tagged accordingly, I'm not claiming otherwise, the store page carries the disclosure and anyone can check. What I did do to all of it: every sprite gets alpha-weighted mean luma measured against the palette thresholds before it ships, the same gate the placeholder polygons had to pass, textures get no exemption.

Closing

A bug once survived a test written specifically to catch it: the test reached the state it wanted to inspect by calling a seek function, and that function re-ran the layout pass on every call, so the test path repaired the exact bug it was checking for and reported green while the real thing was broken on screen.

What I learned

A test that reaches the state by a different path than production is testing a different program. The same week, twenty minutes of actually playing surfaced a dead end that 24 passing suites walked straight past.