Case study · 2026
Make It Fit
A funny packing game whose puzzles are checked by a solver.
I built Make It Fit to see whether a playful, hand-drawn game could also be rigorously correct. I designed its three levels, then verified each with a solver written in a different language from the game.
Play the game (opens in a new tab) GitHub repository (opens in a new tab)
01
The game and the problem
The game. Drag and rotate belongings onto a grid, stay under the weight allowance and collect enough Chaos to win. The items are the joke: a marble foot, a garden gnome, a flamingo, a hotel painting that has to come along.
The problem. Playful is easy. Fair is harder:
- Dragging has to feel loose while the rules stay exact.
- Every level must be solvable, and its winning combinations known in advance.
- Level 1 is designed so it cannot be solved without rotating.


Design
Original art, tactile play
Every illustration is original SVG drawn in code. The layout adapts from 320 px phones to desktop, and nothing above the suitcase shifts while you play.
02
Architecture
Rules apart from the interface
- Python solververify_levels.py
- Level dataVERIFIED_LEVELS.json
- Rules enginepure TypeScript, no React
- React interfaceLevelPlay.tsx, App.tsx
The engine knows nothing about the screen, so it is tested without a browser. Its state is immutable: every move returns a new state or a typed error. React handles dragging, keyboard and dialogs; pointer and layout maths and every sentence the game announces are separate pure functions.
React 19, TypeScript and Vite, as a static single-page app.
03
The key decision: an independent solver
A hand-designed puzzle can be unsolvable without anyone noticing. So each level is designed first, then checked by an exhaustive search.
verify_levels.py is plain Python (standard library only). It tries every combination of items and writes the verified level data and solution fixtures the game loads. It implements the same rules as the game in another language and code base, which makes a rules bug much less likely to go unnoticed, though it cannot rule one out. Level 3, for example:
- 532item combinations that fit and stay within the weight limit
- 37of them reach the win target
- 1reaches the maximum Chaos
Three layers keep the two implementations honest:
- 01
CI re-runs the solver
The workflow regenerates the level data and both solution fixtures and fails if any file differs from what is committed.
- 02
The engine accepts its answers
engine.test.ts replays the solver’s reference layouts through the TypeScript rules: every one fits the board without overlap, wins, and the perfect layout reaches the maximum Chaos.
- 03
A second derivation
enumerate.test.ts re-derives which item sets fit and stay within the weight allowance, so the search and the data are checked from the TypeScript side too.
Limit: the third check reuses the engine’s shape and rotation code, so it verifies the search and the data, not the rotation maths itself.

Result
Tuned, not guessed
Level 3 is a 7×6 suitcase with a 20.5 kg allowance. Each win target is 80% of the level’s perfect score, rounded up (24 of 29 here), and the game never reveals which combination is perfect.
04
Interaction engineering
- DragRe-render when the target cell changes, not on every pointer move
- Drag rendering was a real problem on WebKit. Pointer position now lives in refs and is applied once per animation frame, and React updates only when the snapped cell changes. A Playwright test runs a scripted drag of about 250 pointer moves and asserts at most 60 React commits; a comment in the test records that the earlier version committed roughly once per move. It counts commits only: it is not a frame-rate benchmark, and Chrome’s CPU timings are printed but never asserted.
- GridItems are sets of grid cells
- Fit, rotation and snapping are exact integer maths with two failure modes, out of bounds and overlap, so they are easy to test exhaustively. The alternative, free-form geometry, would allow organic shapes at the cost of fuzzy collision.
- KeysEvery action works without a mouse
- Tab to an item, Enter to pick up, arrows to move, Enter to place; R rotates, Esc cancels, Delete removes, Ctrl/⌘+Z undoes. Score and weight changes are announced through live regions and reduced-motion settings are respected. Good-faith accessibility, not an audited result.
05
Verification
I ran the game’s unit suite on 20 Oct 2026: 239 Vitest tests in 22 files passed.
- Unit tests cover the engine, solver fixtures, saved-state parsing and migration, layout and drag maths, announcements and the sound system.
- Browser tests (Playwright) drive real drags, rotation, keyboard play and persistence at phone, tablet and desktop sizes: Chrome, Playwright’s WebKit and a Firefox subset, against both the dev server and the production build.
- Production checks fail the build if developer tools or solution data reach
dist, and a test runs the game under its shipped Content-Security-Policy with no violations. - CI (GitHub Actions) runs type check, lint, unit tests, the solver diff and the browser suite as parallel per-engine jobs. Timing-sensitive tests run alone so a busy runner cannot distort them. CI checks the code; it does not deploy.
I did not re-run the browser suite or CI for this page.
06
Decisions and trade-offs
- ASaved data is untrusted input
- Progress and boards are versioned and validated on every read. A saved board is replayed through the engine; if the level changed or any placement is now illegal, the level starts empty. If storage is blocked, the game still plays from memory.
- BWeight is advisory while packing
- Over-limit drops are allowed and explained; a level is won only when the total is within the allowance. Refusing the drop felt worse to play. This is a design judgement, not a measured result.
- COne large interaction component, on purpose
- The drag refs, handlers and effects in LevelPlay.tsx depend on each other, so splitting them would add indirection without making them testable on their own. The parts that can be tested in isolation, the rules and every announced sentence, live in src/engine and status.ts.
- Trade-offStatic by design
- No server, accounts or requests after load, so hosting is simple and there is no backend or user data to secure (the site still ships a strict Content-Security-Policy). The price is that progress stays in one browser and there are no leaderboards.
- Trade-offArt as code
- SVG drawn in code means no image files to license or load, and it scales with the board. The cost is very large art modules.
07
Outcome and limits
Three levels, released and playable at make-it-fit.pages.dev.
- No formal playtesting: Chaos values and difficulty are design choices, not measurements.
- Accessibility is built in but unaudited. I have played it on a real iPhone, but desktop Safari, Safari’s text-size setting and VoiceOver are untested, and the WebKit tests use Playwright’s engine rather than Safari.
- Only three levels; adding one means running the solver and regenerating its data.
More detail:README verification (opens in a new tab)architecture notes (opens in a new tab)
08
Read the code
github.com/Novaav/make-it-fit (opens in a new tab)
- Rulesgame.ts (opens in a new tab)engine.test.ts (opens in a new tab)
- Solververify_levels.py (opens in a new tab)enumerate.test.ts (opens in a new tab)
- Drag and keyboardLevelPlay.tsx (opens in a new tab)drag-performance.spec.ts (opens in a new tab)
- Saved stateboards.ts (opens in a new tab)boards.test.ts (opens in a new tab)
- Production checks_headers (opens in a new tab)prod.spec.ts (opens in a new tab)
Source-available under an all-rights-reserved licence, not open source. Figures and file references come from the repository’s README, architecture notes, level design notes, code and tests (the unit suite was run on 20 Oct 2026).