No description
  • Rust 93.4%
  • HTML 4.8%
  • Nix 1.8%
Find a file
2026-09-19 21:37:12 +02:00
assets add sfx, sprites, TODO: fix player sprite and animation 2026-09-19 21:37:12 +02:00
src add sfx, sprites, TODO: fix player sprite and animation 2026-09-19 21:37:12 +02:00
.envrc MVP working, needs mitty sprite, maybe wings mechanic (add toggle) 2026-09-12 16:39:18 +02:00
.gitignore MVP working, needs mitty sprite, maybe wings mechanic (add toggle) 2026-09-12 16:39:18 +02:00
Cargo.lock add sfx, sprites, TODO: fix player sprite and animation 2026-09-19 21:37:12 +02:00
Cargo.toml add sfx, sprites, TODO: fix player sprite and animation 2026-09-19 21:37:12 +02:00
flake.lock MVP working, needs mitty sprite, maybe wings mechanic (add toggle) 2026-09-12 16:39:18 +02:00
flake.nix MVP working, needs mitty sprite, maybe wings mechanic (add toggle) 2026-09-12 16:39:18 +02:00
index.html MVP working, needs mitty sprite, maybe wings mechanic (add toggle) 2026-09-12 16:39:18 +02:00
og.png MVP working, needs mitty sprite, maybe wings mechanic (add toggle) 2026-09-12 16:39:18 +02:00
README.md add sfx, sprites, TODO: fix player sprite and animation 2026-09-19 21:37:12 +02:00
Trunk.toml MVP working, needs mitty sprite, maybe wings mechanic (add toggle) 2026-09-12 16:39:18 +02:00

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:

  1. Create — drop in an image, pick a difficulty, and download a .pzl file. The image is cropped to a clean aspect ratio and wrapped in a custom encrypted container together with the difficulty setting.
  2. Play — drop in a .pzl file 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 .wasm as application/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 image crate (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.