A zero-dependency, configurable web puzzle game. Drop two files into your site, write a JSON config, and you have a working jigsaw puzzle — drag-and-drop, 3D piece flips to reveal descriptions, a timer, and emoji celebrations on completion.
Designed as a generic open-source component: use it as a personal-site conceit, a classroom activity, or any context where "put this together to learn more" adds value.
- Drag-and-drop with snap-to-slot and bounce animation
- Two layout types: built-in rectangle grid (with piece-count presets), or custom SVG interlocking pieces
- 3D card flip + full-height info panel — click any piece to open its description
- Markdown descriptions —
descriptionfields support headings, bold, italic, lists, and links - Timer starts on first drag, pauses while the info panel is open
- Escalating emoji celebration — 5 waves of emojis, growing in count and speed each second; 🏆 mixed in when you beat your fastest time
- Word-by-word completion overlay — auto-dismisses after 5 seconds; piece labels fade back in so you can keep exploring
- Persistent scores via
localStorage— tracks fastest time, completion count, history - Localization — flag buttons switch languages live; text fields accept a plain string or a
{code: text}object - Zero dependencies — vanilla JS + CSS, no build step
cd /path/to/Puzz
python3 -m http.server 8787
# open http://localhost:8787Swap config.json and sample.jpg with your own content to get started.
<div id="puzzle" style="width:100vw; height:100vh;"></div>
<link rel="stylesheet" href="puzz.css">
<script src="puzz.js"></script>
<script>
new Puzz(document.getElementById('puzzle'), 'config.json').init();
</script>The container element can be any size — Puzz fills it.
{
"image": "my-photo.jpg",
"title": "My Puzzle",
"subtitle": "Drag the pieces into the frame",
"layout": { "type": "grid", "cols": 3, "rows": 2 },
"storageKey": "my-puzzle",
"completionMessage": "You put it all together!",
"celebrationEmojis": ["🎉","🌟","✨","🎊","🦋","🌈","🔥","💫","🎈","🍀"],
"pieces": [
{ "id": "p1", "title": "About", "subtitle": "who I am", "description": "Long text shown on the back of this piece." },
{ "id": "p2", "title": "Work", "subtitle": "what I do", "description": "..." },
{ "id": "p3", "title": "Skills", "subtitle": "how I do it", "description": "..." },
{ "id": "p4", "title": "Play", "subtitle": "side quests", "description": "..." },
{ "id": "p5", "title": "Writing", "subtitle": "thoughts", "description": "..." },
{ "id": "p6", "title": "Contact", "subtitle": "say hello", "description": "..." }
]
}| Field | Required | Description |
|---|---|---|
image |
✓ | Path to the background image (jpg, png, webp) |
title |
✓ | Shown in the center of the frame before any pieces are placed |
subtitle |
Smaller instruction text below the title | |
layout |
Layout config — see Layouts. Default: {"type":"grid","cols":3,"rows":2} |
|
storageKey |
localStorage namespace. Use a unique value per puzzle. Default: "puzz" |
|
completionMessage |
Text overlaid word-by-word when the puzzle is solved | |
celebrationEmojis |
Emoji pool for bonus celebrations. Overrides the built-in default list | |
pieces |
✓ | One entry per piece, in reading order (left-to-right, top-to-bottom) |
| Field | Required | Description |
|---|---|---|
id |
✓ | Unique identifier: "p1", "p2", … |
title |
✓ | Shown on the front face of the piece |
subtitle |
Smaller text below the title on the front face | |
description |
Content shown in the info panel when the piece is flipped. Supports markdown — see Markdown in Descriptions. |
Any user-facing text field — top-level title, subtitle, completionMessage, and each piece's title, subtitle, description — accepts either a plain string (single language, unchanged behavior) or an object keyed by language code:
"title": { "en": "My Puzzle", "es": "Mi Rompecabezas", "pt": "Meu Quebra-cabeça", "fr": "Mon Puzzle" }Declare which languages are available (and how their switcher button looks) with a top-level languages array:
"languages": [
{ "code": "en", "flag": "🇺🇸", "label": "English" },
{ "code": "es", "flag": "🇲🇽", "label": "Español" },
{ "code": "pt", "flag": "🇧🇷", "label": "Português" },
{ "code": "fr", "flag": "🇨🇦", "label": "Français" }
]With 2+ languages configured, flag buttons appear in the bottom-left of the footer. Clicking one re-renders the puzzle's text in that language. Structural fields — image, layout, storageKey, mobileMode, celebrationEmojis — are shared across all languages and are never localized, so the same puzzle and score history carry over regardless of language.
The active language is resolved on load in this order: last choice saved in localStorage (per storageKey) → browser language (navigator.language) if it matches a configured code → the first entry in languages → "en". Omit languages (or list only one) and no switcher is shown — existing single-language configs work unchanged.
A handful of built-in UI strings (the footer's "Solves"/"Fastest" labels and the restart button's tooltip) ship translated for en, es, pt, and fr in Puzz.UI_STRINGS.
Divides the image into a uniform rectangle grid. No SVG or extra files needed.
Explicit cols/rows:
"layout": { "type": "grid", "cols": 3, "rows": 2 }Piece-count shorthand (recommended for common sizes):
"layout": { "type": "grid", "pieces": 9 }pieces value |
Grid |
|---|---|
4 |
2 × 2 |
6 |
3 × 2 |
9 |
3 × 3 |
15 |
5 × 3 |
25 |
5 × 5 |
The pieces[] array must have exactly as many entries as the grid has cells.
Interlocking or irregular piece shapes, defined by SVG paths you draw or generate.
"layout": { "type": "custom", "src": "layouts/my-layout.json" }The layout JSON is generated from an Inkscape SVG — see Creating Custom Pieces below.
Two paths: draw them in Inkscape, or generate them parametrically.
Requirements for the SVG:
- One
<path>per piece in a single Inkscape layer - Each path must have an
inkscape:labelattribute that names the piece - Labels must match the
PIECE_ORDERlist inbin/extract_layout.py(edit that list if your labels differ from the default) - Default label set:
top-left,top-center,top-right,bottom-left,bottom-center,bottom-right - All paths must be closed (end with
Z) - Pieces must fully cover the puzzle area with no gaps between neighbors
Three example SVG files are included in graphics/ — see Included Piece Sets.
bin/generate_jigsaw.py produces classic round-tab interlocking jigsaw shapes programmatically, with no manual drawing required.
python3 bin/generate_jigsaw.py graphics/my-pieces.svgThe generated SVG is immediately ready for bin/extract_layout.py. Shape is controlled by constants at the top of the script:
| Constant | Default | Description |
|---|---|---|
ROWS, COLS |
2, 3 |
Grid dimensions |
SEED |
7 |
Random seed for tab in/out direction per edge |
NECK_X |
0.42 |
Where the tab neck begins (fraction of edge length, 0–0.5) |
BULB_CY |
0.30 |
Tab height as fraction of edge length |
BULB_R |
0.17 |
Tab bulb radius |
ATTACH_DROP |
34 |
Degrees from base where neck attaches to bulb |
Once you have an SVG (hand-drawn or generated), run bin/extract_layout.py to produce the layout JSON:
# From the project root:
python3 bin/extract_layout.py graphics/my-pieces.svgThis computes the tight bounding box of each piece (including cubic bezier extrema), assigns piece IDs in PIECE_ORDER order, and writes a JSON file next to the source SVG. Move it into layouts/:
mv graphics/layout-my-pieces.json layouts/Then point your config at it:
"layout": { "type": "custom", "src": "layouts/layout-my-pieces.json" }Note on output filename: If your SVG is named puzzle-pieces-N.svg, the output is layout-N.json. Otherwise the script appends .json to the base filename.
Note on PIECE_ORDER: The list near the top of bin/extract_layout.py controls the order pieces are assigned IDs (p1, p2, …) and therefore which config pieces[] entry corresponds to which shape. Update it if your piece labels differ.
| SVG | Description | Layout JSON |
|---|---|---|
graphics/6-geometric.svg |
Geometric 6-piece design, hand-drawn in Inkscape | layouts/6-geometric.json |
graphics/6-hand-drawn.svg |
Interlocking jigsaw with tabs, hand-drawn in Inkscape | layouts/6-hand-drawn.json |
graphics/6-generated.svg |
Parametric classic round-tab jigsaw (generated by bin/generate_jigsaw.py) |
layouts/6-generated.json |
graphics/6-irregular.svg |
Human-shaped hand-drawn interlocking pieces | layouts/6-irregular.json |
The demo (config.json) uses 6-geometric.json by default. To switch layouts, update the "src" value in config.json.
Clicking (without dragging) any piece:
- Flips the piece (3D animation)
- Opens a full-height centered panel with the piece's title, subtitle, and description
- Pauses the timer — time spent reading doesn't count against your score
Click the backdrop, the ✕ button, or press Escape to close. The timer resumes from where it paused.
The description field supports a subset of Markdown:
| Syntax | Result |
|---|---|
## Heading |
<h2> |
### Subheading |
<h3> |
**bold** |
bold |
*italic* or _italic_ |
italic |
- item or * item |
bullet list |
| blank line | paragraph break |
[text](url) |
link (opens in new tab) |
In JSON, use \n for newlines. VS Code and most JSON editors handle this cleanly.
"description": "## Mountains\n\nTowering peaks over **14,000 feet**.\n\n- Rocky ridgelines\n- Alpine meadows\n- Snow year-round"Puzz stores scores in localStorage under config.storageKey:
{
"completions": 4,
"fastestTime": 83421,
"lastTime": 91200,
"history": [120000, 95000, 91200, 83421]
}On load, fastest time and completion count appear in the HUD. On each completion, 5 waves of emojis fire — one per second, each wave larger and faster than the last:
| Wave | Emojis | Spawn gap |
|---|---|---|
| 1 | 20 | 45 ms |
| 2 | 25 | 35 ms |
| 3 | 30 | 25 ms |
| 4 | 35 | 15 ms |
| 5 | 40 | 8 ms |
If you beat your previous fastest time, 🏆 is mixed into the emoji pool at roughly 50/50 with the celebrationEmojis list. Customize the base pool via config.celebrationEmojis.
The completion message overlay appears after 1.5 seconds and fades out after 5 seconds, leaving the completed puzzle visible and fully interactive — click any piece to read its description.
cd /path/to/Puzz
python3 -m http.server 8787
# open http://localhost:8787/test/puzz.test.htmlThe test page covers _fmtTime, GRID_PRESETS, _gridLayout, and _renderMarkdown (the pure functions). All results appear as green ✓ / red ✗ in the browser.
Puzz/
puzz.js # Core engine — class Puzz, zero dependencies
puzz.css # All styles
index.html # Demo page
config.json # Demo config — edit to customize
sample.jpg # Demo image
bin/
extract_layout.py # SVG → layout JSON
generate_jigsaw.py # Parametric round-tab jigsaw SVG generator
graphics/
6-geometric.svg # Geometric 6-piece design, hand-drawn in Inkscape
6-hand-drawn.svg # Interlocking jigsaw tabs, hand-drawn in Inkscape
6-generated.svg # Parametric classic jigsaw (output of generate_jigsaw.py)
6-irregular.svg # Human-shaped hand-drawn interlocking pieces
layouts/
6-geometric.json # Extracted layout for 6-geometric.svg
6-hand-drawn.json # Extracted layout for 6-hand-drawn.svg
6-generated.json # Extracted layout for 6-generated.svg
6-irregular.json # Extracted layout for 6-irregular.svg
test/
puzz.test.html # Browser test page (serve and open in browser)
MIT