How I Built AI Puzzle Maker
I built AI Puzzle Maker ๐งฉ for the Gradio ร Hugging Face Build Small Hackathon โ a real, satisfying jigsaw that lives entirely inside one Hugging Face Space. You type a one-line subject โ "a lighthouse in a storm," "my cat as a samurai" โ and an in-Space pipeline paints it, slices it into bezier-tabbed pieces, scatters them across the table, and starts a clock. A little mascot hops around the board the whole time, hyping your streaks and roasting your stalls.
One idea organized every decision: the picture is AI-generated and different every time, but the jigsaw itself is a fixed, hand-built engine. Cutting, piece-to-piece snapping, rotation, timing โ all of it works for any image. That single constraint is what turns an arbitrary prompt into a fair, satisfying puzzle.
โถ๏ธ Watch the demo on YouTube โ half a minute: type a subject, watch it paint, then race the clock as the jigsaw snaps together.
Architecture
A few small models behind one route, a content-safety gate off on Modal, and a Hugging Face Bucket holding everything that has to persist across players.
Painting the puzzle with FLUX.2-klein-9B
The artwork is the hook, so it had to be both beautiful and playable โ and FLUX.2-klein made that easy. It's a distilled model: four steps, guidance 1.0, and a single GPU attach renders both the 1024ร768 puzzle artwork and the 512ร512 mascot sprite at once. I keep the ZeroGPU reservation tight (45 seconds) on purpose โ over-reserve, and one player's generation locks the next one out with "quota exceeded" even when there's GPU to spare.
There's a subtlety specific to puzzles: a big empty sky means a fistful of identical blue pieces, which is misery, not difficulty. So the image prompt always carries a fixed tail demanding interesting detail in every region โ corners, sky, foreground. The mascot renders on a flat magenta #FF00FF background that sprites.py chroma-keys into a transparent sprite; the key is hue-aware (magenta-dominant pixels where red and blue both clearly exceed green), so it removes the #FF00FF and the darkened drop-shadow FLUX likes to add โ and despills the anti-aliased fringe โ without eating real colors. Locally, no GPU and no torch, flux_local.AVAILABLE is False and the game quietly falls back to bundled puzzle art. The jigsaw is fully playable either way.
Cutting a real jigsaw in the browser
This is the hand-built half, and the part I'm proudest of. The engine is one HTML5 canvas, vanilla JS, zero frameworks. cutImage() walks the grid and, for every interior edge, builds a randomized bezier tab out of six cubic segments โ a run-in, a pinch into the neck, two curves around the bump, a run-out. The trick that makes pieces actually mate: the two neighbours sharing an edge draw the exact same world-space curve, one forward and one reversed, so the tab on one piece is precisely the blank cut from the other. Each piece is clipped onto its own padded canvas (the pad is 36% of a cell, room for the tab to poke out) and double-stroked โ a light inner line, a dark outer one โ so it reads as a physical piece. Difficulty is 12 / 24 / 48 / 96 pieces, just a different rows ร cols grid into the same cutter.
Pieces don't only snap to the board โ they stick to each other. Every piece starts as its own cluster (grp = [piece]). When you drop a correct neighbour at the right relative offset and matching angle, joinGroups() glides the moving cluster into exact alignment and merges the two groups into one; from then on the whole chunk drags, rotates, and snaps as a rigid body. tryPlace() checks three things in order: is any cluster member over its own board slot (lock the whole cluster in), is any member adjacent to a correctly-oriented stranger at the right offset (stick them together anywhere on the table), or is this a deliberate wrong fit (right slot, wrong angle) worth a buzz.
The invariant that keeps all of this honest is realignGroup(). Cluster members are addressed by their grid coordinates (r, c), so from any anchor I can recompute every other member's exact x / y / rot โ rotate the relative offset by the anchor's angle, add it on. That means after a window resize, an interrupted snap tween, or a 90ยฐ turn, one call restores the cluster to perfect alignment with no accumulated float drift. Rotation is then trivial: a rigid 90ยฐ turn of the whole cluster about the grabbed piece. Rotation mode scatters pieces at random angles โ R, right-click, or double-tap to spin โ and roughly doubles the difficulty without touching a line of the cutting code. The clock starts on your first grab, not before; snap streaks ring up a little pentatonic WebAudio arpeggio (no audio files anywhere); a ๐ toggle ghosts the finished picture onto the board when you're stuck.
MiniCPM gives every puzzle a voice
"Different picture every time" would feel hollow if the personality didn't change too. So one MiniCPM4.1-8B call turns your one-line subject into a complete, cohesive puzzle pack: a title, a gallery blurb, a mascot name, the mascot's commentary voice, the FLUX prompts for both artwork and sprite, six starter quips, and a victory line. Two divergent worked examples โ a cozy alpine cabin and a neon cyberpunk street โ are the few-shot anchors, chosen precisely so the model learns to re-theme everything rather than copy one, and an _enforce() pass guarantees the structure and the technical prompt tails survive even when the model slips. MiniCPM4.1 is a thinking model, so the app asks it not to think and strips <think> blocks defensively.
The same model handles live commentary through the /quip route โ fresh hints, roasts of your pace, victory lines, all in the mascot's voice. If MINICPM_URL/MINICPM_KEY aren't set, the browser falls back to canned lines, so the mascot keeps its mouth running even offline.
The content-safety gate on Modal
Players type anything, so before a single brushstroke I screen the typed subject through a real classifier. This is the freshly-added piece of the project, and the one I'd most want to reuse.
The model is NVIDIA Nemotron-3.5-Content-Safety โ a Gemma-3 4B (public, not gated) that I host on a Modal L4 GPU and call as a tiny token-protected HTTP endpoint. modal_safety.py is the service: it bakes the weights into the container image so cold starts don't re-download, loads the model in bf16, and applies the model's own chat template โ which ships the Aegis-V2 safety policy and taxonomy. I pass request_categories so it doesn't just answer safe/unsafe but lists the categories an input trips, then parse the verdict back into {safe, categories}. moderation.py is the client side: pure-stdlib urllib, no new dependencies in the Space, fail-open by construction.
The policy is deliberately surgical, and it lives entirely in the client. The Aegis taxonomy has 24 categories (S1โS24); the model flags whatever it sees, and moderation.py blocks only a subset โ the default SAFETY_BLOCK is S2,S3,S5,S6,S7,S8,S9,S10,S11,S16,S17,S22: sexual content, minor-sexual, self-harm, hate, real-world weapon/drug instructions, fraud, malware, harassment, threats, PII, illegal activity. Everything else โ including S1 Violence and S4 Guns/Weapons โ is allowed, on purpose. So "a knight with a sword," "a pirate battle," a dragon's fiery brawl all sail straight through, while "how to build a bomb" is stopped cold. Because the blocklist is just an env var (codes or names, normalized against a code โ name table), I can re-tune the per-category policy with no Modal redeploy; MODERATION=0 is a kill switch.
Two design calls I'd repeat. First, the gate is fail-open: any error, timeout, or cold start allows the generation โ a safety hiccup must never block the game, so a flaky model is strictly better than a blocked player. Second, it lives off the Space on Modal, which scales to zero (a scaledown_window keeps it warm five minutes after the last call, then it costs nothing). A background prewarm() fires one throwaway check at startup to absorb the cold start so the first real player isn't the one who pays for it. The client only needs a URL and a token, so one deployment can back many Spaces.
Timed challenges and a community gallery on an HF Bucket
The clock starts on your first grab. Hints flash the right slot but cost +15s, and your fastest solves land on each puzzle's solve-time leaderboard. All of it persists on a Hugging Face Bucket mounted at /data, with one rule everywhere: one immutable file per record. A solve is keyed by (puzzle id, piece count) โ the only fair comparison โ and saved as times/<pid>~<pieces>~<ms>-<ts>.json, with the milliseconds zero-padded straight into the filename. So "fastest N" is just "list keys with that prefix, sort ascending, read the first few," and computing where a fresh time would rank needs no content reads at all โ just a filename scan. Published puzzles are opt-in: press SHARE, name yourself, and the whole pack saves as one object plus a tiny "card" for fast gallery listings. Even likes (โค) are one empty file each (likes/<id>~<token>). No read-modify-write anywhere means no race conditions to babysit, and concurrent players never collide.
The ZeroGPU postMessage bridge
My biggest time-sink was invisible. The game runs in an iframe, and a raw fetch() from inside that iframe can't attach ZeroGPU's visitor token โ so those calls arrive identity-less and get zero GPU quota ("0s left"), for every account. The fix: generation runs as a real Gradio event on the outer page (whose native client does carry the token), and the game iframe drives it over a postMessage bridge โ ajp-ping/ajp-pong to detect the bridge, ajp-gen to run a generation. It's also exposed as a Gradio API (generate_puzzle) so the ZeroGPU quota attributes correctly; /generate is the fallback path. It's the kind of thing no doc warns you about until it bites.
What I learned
- Shared geometry is what makes pieces mate. Two neighbours drawing the same bezier curve, plus a realign-from-any-anchor invariant, killed every float-drift bug before it could start.
- Constrain the AI, not the player. "Generated picture, fixed jigsaw engine" is the game: re-skin a balanced engine, never invent mechanics, and any prompt becomes a fair puzzle.
- A safety gate must never break the game. Fail-open, off-Space, scale-to-zero, and tunable per-category by env โ block real harm, allow fictional swords โ moderation that fits the product, not a blanket switch.
- Small, distilled models are the unlock. FLUX.2-klein in four steps, a 4B safety classifier, an 8B writer โ open + small carried the whole game.
- ZeroGPU only funds real Gradio events. A plain
fetch()from an iframe gets zero quota; a postMessage bridge into a hidden Gradio event is the fix. - Boring persistence wins. One immutable file per record on an HF Bucket gave me leaderboards, a gallery, and likes with no database and no locking.
Go make one
It's all running in one Space, persisted in one bucket, with the safety gate humming quietly on Modal. Type something absurd, watch FLUX conjure it in four steps, then race the clock โ and put your time on the board for the next player to chase.
Now piece it together.

