A fast, modern falling-block puzzle game built in Rust on the
Bevy ECS engine (tetris was the working title — see
PRD.md §14 #1 for the rename decision). Single-player marathon plus 1v1
versus — local (shared keyboard, human or bot opponent) and online lockstep
netplay (see Playing online).
Gameplay follows modern community-standard mechanics: SRS rotation with wall kicks and T-spin detection, 7-bag randomizer, hold, ghost piece, lock delay, soft/hard drop, and DAS/ARR input handling.
Mid-game stack with combo streak (left) and a later level (right):
Deep end-game stack, level 11:
Captured from the real renderer via the TETRIS_SHOT hook (see below) with the
greedy bot playing seeded runs.
- Arcade marathon mode — levels, escalating gravity, guideline-style scoring
- 1v1 versus — local (shared keyboard, human or bot) and online lockstep netplay over room codes (relayed, zero-config) or direct IP join, delay-based input sync (default ≈ 133 ms)
- Modern controls feel — DAS/ARR, hard drop, lock delay, ghost piece, hold
- SRS rotation with wall kicks, including basic T-spin detection
- 7-bag randomizer with seeded, reproducible runs (
TETRIS_SEED=<u64>) - Title / pause / game-over screens, settings screen with full key
rebinding and volume control (persisted to
settings.json) - Audio & juice — SFX, music, line-clear flash/shake/freeze effects
- Built-in greedy bot for soak tests (
TETRIS_BOT=1) - Deterministic, engine-free simulation core (
crates/tetris-core), unit- and property-tested withproptest
| Action | Default |
|---|---|
| Move left / right | ← / → |
| Soft drop | ↓ |
| Hard drop | Space |
| Rotate CW / CCW | ↑ / Z (X too) |
| Rotate 180 | A |
| Hold | C (Shift too) |
| Pause | Esc / P |
Versus & online matches. The two seats use the fixed P1/P2 presets (not the solo bindings above): host/left = P1 (WASD cluster), guest/right = P2 (arrows). There is no pause in online matches — Esc there opens a leave-with-confirm instead.
From the title screen: Online → Host or Join. The host listens on
UDP port 27015. Two ways to reach each other: room codes — share a
5-character code, no IP, no router config (the primary path, works across any
NAT) — or direct IP for LAN and advanced setups. Both peers then run the
identical deterministic match from a shared seed; inputs land with a
negotiated delay D = max(host, guest), 8 ticks ≈ 133 ms by default
(TETRIS_NET_DELAY). Host and guest both see both boards rendered locally —
there is no video/state streaming, only inputs.
- Room codes (the way to play with a friend across the internet). When you
Host, the Host screen shows
Room ABCDE — share with a friendbelow the connect hint. Your friend opens Online → Join (it opens in Code mode), types the five characters, and the netplay gateway introduces them and relays the match — neither side needs a public IP, a port forward, or even to know the other's address. The relay only ever sees the game's encrypted netcode traffic; the code is the only secret. Hosting opens the host's router for the relay automatically via a UDP punch (UPnP if you have it); when a router still refuses, the Join screen says so plainly instead of hanging. Failures name themselves while you are still looking at the screen:no such room,match full(someone else already joined),gateway offline — check connection or join by IP,host unreachable — ask host to enable UPnP or port-forward UDP 27015. The entry toggles to IP mode with the mode button when you would rather type an address. The gateway is configured withTETRIS_GATEWAY=<host:port>(defaultblockfall.opensensor.io:27016; set it to empty to disable the whole feature and go direct-IP only — you can also run your own gateway and point it there). Hosting never fails because the gateway is down: the room line just saysgateway offlineand LAN/direct play proceeds. - Direct IP (advanced / no gateway). Type
ip:portin the Join screen's IP mode (keyboard-only entry: digits, dots, colon — Bevy 0.19 has no clipboard paste). This is the whole story on LAN (plus the host firewall); over the internet you need one of the two mapping paths below. - Automatic router port mapping (UPnP). When you Host, Blockfall asks
your router (UPnP IGD) to forward UDP 27015 to your machine, then shows
Friends join at <public-ip>:<port>— hand that to your guest and play cross-WAN with zero router clicks. The lease is renewable (3600 s, refreshed every 30 min while you host) and is deleted when you stop hosting (Esc). If the Host screen instead showsUPnP unavailable — forward UDP 27015 manually (see below), that is normal: the router has UPnP disabled, your ISP controls the edge device, or the network blocks SSDP — LAN play and room codes are unaffected. Press U on the Host screen to retry or disable the attempt (remembered in the net profile). - NAT / manual port-forwarding. If UPnP is unavailable and you would rather not use the gateway: for internet play port-forward UDP 27015 to your machine (and allow it through any host firewall); the Host screen shows the host's LAN IPv4 when one exists — over the internet, share your public IP (or the address your router's status page shows) instead.
- Hosted UDP tunnels do not work (2026). Quick tunnels are not an option
for this game: ngrok 3.39 removed the
udpcommand entirely (only http/tcp/tls remain) and cloudflared 2026.9 rejects UDP origins (Currently Cloudflare Tunnel does not support udp protocol). Netplay is raw UDP (netcode) — hence the built-in gateway, which needs only outbound UDP from the guests. - The port is open while listening. v1 uses unauthenticated netcode
(protocol-ID check only): anyone who can reach the port with a matching
protocol version can join while you are listening. Keep sessions short and
press Esc on the Host screen (
net_stop) when you are done. - No lobby, no accounts. Room codes are introduction-only — nothing is persisted, and an unhosted room expires in ~15 s. A direct-IP join failure (≈10 s timeout) still reads "host offline or match full" — netcode cannot tell the two apart with one seat (the room-code path can tell you instantly: see the failure lines above).
- Mismatched builds are refused by the version handshake — both sides need the same game version.
- Mid-match, Esc opens a confirm ("Leave match?"); leaving sends a graceful bye so the opponent sees "opponent left". A desync freezes both boards and offers a return to title.
Cross-WAN play without port forwarding: a netplay gateway is a tiny
zero-dependency relay (crates/netplay-gateway) that introduces host and
guest and forwards their encrypted match traffic — guests only need the
5-character room code, no public IP or router config on either side. Run it
via Docker or the hardened systemd unit, both documented in
crates/netplay-gateway/README.md; the
box needs inbound UDP 27016–27999 open. Point your game at it with
TETRIS_GATEWAY=<host:port> (the shipped default is the public relay; empty
disables room codes). With the gateway down, clients
fall back to today's direct IP join and UPnP unchanged — hosting never
depends on it. cargo run -p netplay-gateway -- --self-test verifies a build
end-to-end (register, lookup, relay both directions, busy, teardown) on
loopback with zero setup.
Requires a stable Rust toolchain (pinned to 1.95 via rust-toolchain.toml,
which doubles as the project MSRV; clippy and rustfmt are included):
rustup show # installs the pinned toolchain on first runRun the game from the workspace root:
cargo runAudio assets & working directory. The Bevy asset server resolves
crates/tetris-app/assets/(SFX/BGM WAVs) relative to the process CWD. Running viacargo run(from the repo root or fromcrates/tetris-app) is always correct. Running the built binary directly (e.g.target/release/blockfall) must be done fromcrates/tetris-app/, or with theassets/directory placed next to the executable (packaging concern, tracked for T20).
The binary is named blockfall (cargo run -p tetris-app also works; the
package id intentionally stays tetris-app).
| Variable | Effect |
|---|---|
TETRIS_SEED=<u64> |
Seed the 7-bag RNG for reproducible runs |
TETRIS_BOT=1 |
Greedy solver plays marathon (soak tests, captures) |
TETRIS_CONFIG_DIR=<path> |
Override the settings/best-score directory |
TETRIS_SHOT=<paths> |
Window screenshots via Bevy's built-in Screenshot pass: a.png@90,b.png@1200 captures at the given Update frame numbers |
TETRIS_NET=host:<port> / join:<ip:port> |
Desktop netplay harness: bot-vs-bot-across-the-wire (Garbage → Race), logs NET match_done/final_hash, exits 0 on matching hashes, 1 on desync/loss/stall |
TETRIS_NET_DELAY=<2..=30> |
Desired input delay in ticks for online matches (default 8 ≈ 133 ms); both peers adopt max(host, guest) |
TETRIS_NET_FORK=guest:<tick> / host:<tick> |
Test hook: fork the named peer's mirror at the given tick, proving desync detection fires |
Example — capture a mid-game stack:
cd crates/tetris-app
TETRIS_BOT=1 TETRIS_SEED=7 \
TETRIS_SHOT=/tmp/shot.png@900 \
../../target/release/blockfallThe game ships as an Android cdylib (libblockfall_app.so) loaded by a
NativeActivity (winit native-activity backend, #[bevy_main] entry point),
subclassed by dev.blockfall.app.Main (android/java/..., compiled to
classes.dex by javac + d8) purely for immersive-fullscreen enforcement —
the nav pill and status bar are hidden sticky-style, so nothing overlays the
playfield. No Gradle: build and package with NDK + build-tools directly —
export ANDROID_HOME=/path/to/android-sdk # needs a platform, build-tools, ndk
./scripts/build-android.sh # javac/d8 + cargo-ndk + aapt2 + zipalign + apksigner
$ANDROID_HOME/platform-tools/adb install -r target/android/blockfall-debug.apkThe debug keystore is generated on first run (android/debug.keystore,
git-ignored). The APK is portrait-native (portrait, targetSdk 34 —
Android 15+ ignores system-bar hiding for apps targeting 35+): the field
fills a vertical column with a HUD strip above (score/level/lines, hold box
and horizontal next queue) and a compact button deck below. Controls
(crates/tetris-app/src/touch.rs): playfield gestures — tap to rotate,
drag left/right to shift, swipe down to soft-drop, flick down to hard-drop —
plus discs for the rare actions (CCW CW HOLD DROP + II pause), all
feeding the same DAS/ARR pipeline as the keyboard. A landscape button deck
(< > v / CCW CW DROP / HOLD) still builds and is shown for rotated or
desktop windows (TETRIS_TOUCH=1 smoke-tests the buttons,
TETRIS_PORTRAIT=1 the portrait layout). Phone build notes: settings/best
scores persist to the app's internal storage (there is no ~/.config),
hosting relies on the gateway or direct IP (SSDP multicast needs a Java-side
lock), the Online → Join entry auto-focuses the soft keyboard, and the
desktop-only "Quit" buttons are compiled out — exit through
the system back gesture.
crates/tetris-app/assets/icon.png (1024×1024, generated by
crates/tetris-app/assets/generate_icon.py) is the app icon. Bevy 0.19
removed the runtime window-icon API (Window::icon / WindowIcon no longer
exist; verified against bevy_window 0.19.1), so the icon is wired for desktop
integration instead of at runtime:
- GNOME/Wayland shows icons by matching the window app-id against an installed
.desktopfile. Packaging (T20) should install ablockfall.desktopwhoseIcon=points at the hicolor-installed PNG (winit derives the app-id from the binary name). - Revisit a runtime icon when Bevy re-exposes one; until then this is the only
correct placement. The window title ("Blockfall") is applied at runtime by a
registered plugin (
juice.rs), keepingmain.rsfrozen.
tetris/
├── Cargo.toml # workspace root (virtual)
├── crates/
│ ├── tetris-core/ # pure, deterministic game rules (no engine deps)
│ │ # board, pieces, SRS, randomizer, scoring, FSM
│ └── tetris-app/ # Bevy binary: rendering, input, UI, audio
│ └── assets/ # sfx/, bgm_loop.wav, icon.png (+ generators)
├── assets/screenshots/ # README screenshots (TETRIS_SHOT captures)
├── PRD.md # product requirements
├── netplay-plan.md # netplay task plan N1–N8 (online 1v1)
└── tetris-plan.md # task plan T0–T26
tetris-core never links Bevy — the app side bridges a fixed-step simulation
through snapshots, which keeps the rules testable headlessly.
cargo test --workspace # unit + proptest suite (core is headless)
cargo clippy --workspace -- -D warnings
cargo fmt --all --checkGitHub Actions runs fmt --check, clippy -D warnings and cargo test --workspace
on every push/PR (Linux; the runner installs libasound2-dev and libudev-dev
for Bevy's audio/device backends), plus a nightly --ignored soak of
tetris-core. Pushing a v* tag builds release binaries for Linux, Windows and
macOS and attaches them to the GitHub release.
Note: release archives contain the blockfall binary only. The game loads its
assets from crates/tetris-app/assets relative to the current working
directory, so unpack the binary next to a copy of that assets/ directory to
run it. Proper bundling (cargo bundle, desktop file, icon install) is future
work.
Pre-release (see PRD.md for milestones; release/tag flow in tetris-plan.md T22).


