I had eight things in a bag, two things left outside it, and a hint button that said, “A few things need moving.”
Yes. I had gathered that.
The bag belonged to PERSONAL ITEM, my packing puzzle about getting an entire holiday into one included airline bag. The final round has ten belongings, including a ceramic goose that looked considerably smaller in the gift shop. Everything fits. That particular arrangement did not.
I wanted the hint to tell me what I could keep.
Eight legal placements can still be a dead end
I use a six-by-seven board underneath the soft plum lining. Each belonging occupies a list of whole cells. The walking shoe occupies four cells inside a three-by-two rectangle; the empty notch is usable space. The towel occupies six cells folded and three rolled. The goose occupies nine.
With the towel rolled, the last round uses all 42 cells. There is no spare square to absorb a bad arrangement.
My placement check answers a local question: does this object overlap another object or cross the edge? All eight objects in the troublesome bag passed that check. The remaining bottle and charger still couldn't both fit. Empty area alone doesn't say whether the right shapes can occupy it.
I already had a solver that could finish a partial bag while treating every packed object as fixed. That distinction matters. A solution from an empty bag proves the level works. A solution from the current bag proves the current decisions can survive.
The solver represents occupied cells as bits. This is the actual coordinate mapping in packing.ts:
const bit = (x: number, y: number) => BigInt(1) << BigInt(y * level.width + x)
For this board, that gives me 42 possible bit positions. BigInt keeps the whole board in one mask. Two masks overlap when their bitwise AND is nonzero.
I enumerate the legal positions, rotations and towel states for each remaining item, discard duplicate shapes, and search. At each step I choose the item with the fewest currently legal placements. Failed combinations of occupied cells and remaining items get remembered, so the search doesn't keep visiting the same dead end.
That machinery could already answer “can I finish?” My useless hint was what happened when the answer was no.
I searched for the smallest amount of undoing
I changed the question to: which packed objects could I remove to make the rest completable?
First I try removing each single object. If none works, I try pairs, then triples. Each trial asks the same solver to complete the bag with everything else held exactly where it is.
This is the search in hint.ts:
// Prefer disturbing fewer belongings, and prefer recent placements when tied.
const recent = [...placed].reverse()
for (let count = 1; count <= Math.min(3, recent.length); count++) {
for (const removed of subsets(recent, count)) {
const kept = placed.filter((p) => !removed.some((r) => r.id === p.id))
if (solve(level, kept)) return { kind: 'unpack', id: removed[0]!.id, remaining: count - 1 }
yield
}
}
The order expresses two preferences. I would rather disturb one belonging than two, and I would rather start with a recent placement when there is a tie. The returned advice names just the first object to remove. After that removal, another press of Nudge searches the new state.
For the eight-item trap, there are eight single removals, 28 pairs and 56 triples. That's at most 92 subset trials after the original completion attempt. I stop as soon as one works.
I deliberately stop the exhaustive subset search at three removals. Beyond that, I sort packed objects by occupied area and test removing the largest four, then the largest five, and so on. Those larger repairs aren't guaranteed to move the fewest objects. They give me a bounded sequence of concrete suggestions when the bag is badly fragmented. Every suggested removal still comes from a set whose removal makes the remaining arrangement solvable.
The exact troublesome bag now asks for the camera, then the shoe, then the book. The shirt, shorts, sunscreen, rolled towel and enormous ceramic bird stay where they are.

The first repair names the camera. Nothing moves until I press Unpack.
That last part was important to me. Nudge selects and lifts an object as a preview; it doesn't remove it. I still press Unpack. Once the remaining bag is solvable, Nudge previews one placement and I tap to accept it. I can ignore the advice and try something else.
A generator gives me places to stop
A removal search can call the solver dozens of times. I didn't want the hint button to make the bag stop responding while it worked.
planHint is a generator. Each unsuccessful solver attempt reaches a yield, which gives the caller a chance to return control to the browser. The scene advances it in a loop, checking elapsed time after each step, then schedules another animation frame when the elapsed time reaches four milliseconds.
Four milliseconds is a scheduling target, not a hard ceiling. An individual solve call is synchronous. The generator can pause between calls, but it cannot interrupt one expensive call halfway through. I'd need to make the solver itself resumable to guarantee finer slices.
Waiting also makes advice stale. The bag can change while a search is in progress. Before advancing the search, I check the current interaction against the one that started it:
if (
paused.current ||
current.placed !== source.placed ||
current.selected !== source.selected ||
current.rotation !== source.rotation ||
current.rolled !== source.rolled ||
current.phase !== 'packing' ||
dragging.current
) {
hintFrame.current = null
setHintBusy(false)
return
}
Placements are updated with new arrays, so the reference comparison catches a changed bag. Selection, rotation and rolling also matter: I don't want delayed advice to replace the object I'm already handling. A request token prevents an older search from taking over after a newer Nudge.
I tested the bag that actually failed
I saved the exact eight placements from the original dead end. Then I replayed them through real touch input and followed the new advice.
Camera out. Shoe out. Book out. Five objects still packed.
From there, all five remaining placements could be accepted by tapping the lifted hint previews. The bag reached ten out of ten without a reset. I repeated that recovery in Chromium and WebKit; both completed without browser errors.

The recovered bag. The bottle has a channel, the charger has a tiny home beside the goose, and five original placements survived.
Solving every level from empty wouldn't have caught this. I needed a legal arrangement that couldn't be finished, and I needed to test the steps back out of it.
I'll keep that distinction for the next puzzle: save the state where help becomes vague, then make the test start there. A hint earns its button when it can name one useful action and leave the rest of my work alone.