Nova Zandkarimi

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.

Built with
React · TypeScript · Vite · Python · Playwright
Status
Released; live static site on Cloudflare Pages
Scope
Three levels · no timer, account or server
Licence
Source-available, all rights reserved
Screen recording of the live game, about 40 s, silent, looping.

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.
Make It Fit level 1 solved: a boarding-pass dialog reading Packed, with required items 5 of 5 and Chaos 9, over a teal suitcase holding jeans, a T-shirt, toiletries and sneakers.
Level 1, solved. Captured from the live site. Tap to enlarge.
Make It Fit level 2 partway through packing: a red suitcase holding a quilted handbag, a pink flamingo, a pencil case, a framed painting and a toiletries bag, with a ring light, sequined top, feather boa, disco ball and other items still in the tray. A Chaos meter and weight readout sit above the suitcase.
Tap to enlarge.

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

  1. Python solververify_levels.py
  2. Level dataVERIFIED_LEVELS.json
  3. Rules enginepure TypeScript, no React
  4. 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:

  1. 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.

  2. 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.

  3. 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.

Make It Fit level 3 solved with a Maximum Chaos dialog, over a full purple suitcase of illustrated belongings including a sunset painting, a marble foot and a bottle of rum; a Chaos meter shows 29 and the weight reads 20.05 of 20.5 kg.
Tap to enlarge.

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)

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).

Back to projects