- Rust 93.4%
- HTML 4.8%
- Nix 1.8%
| assets | ||
| src | ||
| .envrc | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| flake.lock | ||
| flake.nix | ||
| index.html | ||
| og.png | ||
| README.md | ||
| Trunk.toml | ||
Puzzler
A small browser game in the spirit of Void Stranger's tile-reassembly bonus rooms. It takes any image, slices it into a grid, scrambles the pieces across the picture area of a room that is deliberately larger than the picture, and lets you walk around using the Void Rod to pick tiles up and drop them into pits until the picture is whole again.
It is two apps in one:
- Create — drop in an image, pick a difficulty, and download a
.pzlfile. The image is cropped to a clean aspect ratio and wrapped in a custom encrypted container together with the difficulty setting. - Play — drop in a
.pzlfile and solve the randomised puzzle. When you finish, the picture flashes and the full original image is downloaded.
Everything runs client-side in WebAssembly. The server only serves static files.
Built with Bevy 0.19, Rust 2024, Trunk, and ChaCha20-Poly1305.
Music
Background music is "toothless dancing but it's lofi" by Cindery (source).
The track is embedded in the binary, looped through bevy_audio, and starts on
the first click/keypress (browser autoplay rules; a small AudioContext resume
shim lives in index.html). Mute it with the note button in the bottom-left
corner — the note gets a slash through it when muted. The volume constant is
BGM_VOLUME in src/ui.rs.
Sound effects
Picking a tile up, dropping one into a pit and the committed fall each play a
short embedded .wav (pickup.wav, putdown.wav, fall.wav) via src/sfx.rs.
They sit under the music at a fixed SFX_VOLUME. The SFX button stacked
above the note toggles them independently of the music.
Playing a .wav needs Bevy's wav feature (rodio's hound decoder); it is
enabled alongside audio in Cargo.toml.
Sprites
The player is drawn from two sheets in src/sprites.rs, both embedded in the
binary:
assets/Mitty-Sprite-Sheet.png— the character, 19x18 frames in a 4x5 grid (the frame is stretched to the square board cell when drawn). Columns 0/1 are the two idle frames, column 2 is the staff-use pose (column 3 is reserved and holds the artist's palette legend); rows 0..=3 are down/right/up/left and row 4 holds the four falling frames.assets/mitty-rod-nidus-Sheet.png— the Void Rod, 32x32 frames in a row, where frame 0 is empty and frames 1..=8 are the swing.
The rod is an overlay: its top-left 16x16 quadrant is reserved for the character's cell, the staff sits in the bottom-left quadrant and the "woosh" sweeps through the right half. It is rotated in 90 degree steps to point the staff the way the player faces. Idle loops constantly; a successful rod action locks the player in place and plays the swing (and the staff pose), and the fall plays its four frames once over the fall-out beat after the grace period.
Controls
The player always faces the direction of the last move and cannot turn in place; the Void Rod only ever acts on the tile in front of the player.
| Input | Action |
|---|---|
Arrow keys / WASD |
Step one tile (and face that way) — one step per key press, no auto-repeat |
Space / E / Enter |
Use the rod on the tile ahead: pick it up, or drop a carried tile into a pit |
| Step back within 2s | If you step onto a pit you fall; step back the way you came to survive (your facing does not change) |
| Drag & drop | Drop an image or .pzl anywhere on the window |
| Buttons | Choose files, change difficulty, download, replay, go back |
Picking a tile out of the floor leaves a pit behind, and pits are deadly, so the room has a bit of tension. A fall resets the room to its starting scramble.
As in Void Stranger, a tile can only be dropped into a pit — you cannot swap it with a solid tile. To make that workable, the room is always one ring of cells larger than the puzzle, so there is a permanent supply of empty cells to manoeuvre through. The finished picture does not have to land in the framed spot: it counts as solved anywhere it fits (3×3 = 9 positions with the one-cell margin).
Difficulties
The difficulty sets the longest side of the puzzle; the image's aspect ratio decides the rest, so pictures are never squashed into a square. The room is always one cell larger than the puzzle on every side, so an 8×8 Hard puzzle lives in a 10×10 room.
| Difficulty | Longest side | Square image | 16:9 image |
|---|---|---|---|
| Easy | 4 | 4×4 (16 tiles) | 4×2 (8 tiles) |
| Medium | 6 | 6×6 (36 tiles) | 6×3 (18 tiles) |
| Hard | 8 | 8×8 (64 tiles) | 8×5 (40 tiles) |
| Void | 10 | 10×10 (100 tiles) | 10×6 (60 tiles) |
The source image is centre-cropped to the puzzle's aspect ratio before being sliced. The full original image is stored untouched and is what you download when you solve the puzzle.
Running it locally
The dev environment is provided by Nix + direnv (Rust nightly with the
wasm32-unknown-unknown target, trunk, wasm-bindgen, binaryen, plus the
native desktop libraries Bevy needs). direnv allow in this directory, then:
# web dev server with hot reload at http://127.0.0.1:8080
trunk serve
# native run (for quick logic checks; no file dialogs on native, set
# PUZZLER_FILE=/path/to/file to feed a file in, downloads go to ./puzzler-output)
cargo run
# unit tests
cargo test
Building for the server
trunk build --release
This produces dist/ containing everything the web server needs
(index.html, a hashed *.js, a hashed *_bg.wasm). Copy the contents of
dist/ into the directory ferron serves.
Two server settings are worth double-checking:
- Serve
.wasmasapplication/wasm. - Enable gzip or Brotli — the wasm is ~24 MB raw (it embeds the font and the music), but compresses to a fraction of that.
No server-side code, database or special routing is needed: it is a static site.
The .pzl format
offset size field
0 4 magic b"PZL1"
4 1 format version (1)
5 12 AEAD nonce (random per file)
17 .. ChaCha20-Poly1305 ciphertext
The magic, version and nonce are authenticated as associated data, so editing them makes decryption fail. The encrypted payload contains, in order:
u32 image_len
u8 difficulty
u32 grid_cols
u32 grid_rows
u32 crop_x, crop_y, crop_w, crop_h (centre-crop rectangle)
u32 original_width, original_height
u8 image format code
u8 original image bytes...
A note on the "encryption"
The key is SHA-256(APP_SECRET) where APP_SECRET is a constant compiled into
the wasm binary. Because the app has to decrypt files in the browser, the key
must ship with it, so this is obfuscation, not security. A determined person
can extract the key and read or forge puzzle files. That is fine for a fun
project: the point is that a .pzl is not a plain, directly-viewable image and
light tampering is caught by the AEAD tag. Do not put anything secret in one.
(If you ever want stronger protection, the design would have to change: keep the key server-side and stream decrypted tiles, or encrypt to a key the player supplies. That is out of scope here.)
Project layout
src/
main.rs app setup, screens (states), file routing
crypto.rs .pzl serialisation + ChaCha20-Poly1305
difficulty.rs difficulty -> aspect-aware puzzle + room sizing
image_util.rs decode, centre-crop, resize, Bevy texture creation
file_io.rs browser file pickers, drag & drop, downloads (+ native shim)
gen_mode.rs "Create" screen
play_mode.rs "Play" screen: board, movement, tile actions, win state
ui.rs shared widgets, main menu, button styling
sfx.rs one-shot pick-up/drop/fall sound effects
sprites.rs character + rod sheets, frame maths, animation
theme.rs colour palette
index.html Trunk entry point + canvas
Trunk.toml Trunk build/serve config
flake.nix dev shell (Rust + wasm target + trunk + native libs)
Implementation notes
- The whole picture is uploaded as a single texture and sliced with a Bevy
TextureAtlasLayout; each cell is a sprite whose atlas index is the tile currently sitting there. Re-rendering a move is just updating indices and visibility. - Images are decoded with the pure-Rust
imagecrate (PNG/JPEG/GIF/WebP/BMP/TIFF) and resized to an exact multiple of the grid so tiles are uniform. The longest edge is capped at 1024 px to keep the GPU texture small. - The camera uses
ScalingMode::AutoMin, so the board always fits on screen in both landscape and portrait. - File input uses hidden
<input type="file">elements and window-wide drag/drop listeners; results are handed to Bevy systems through a small thread-safe queue. Downloads use Blob object URLs.