A native, cross-platform recreation of Tetris Classic (Spectrum HoloByte, 1992) written in Odin, rendered with raylib, with online multiplayer over TCP.
Background artwork from the original is trademarked and intentionally omitted; each level currently has its own background colour (same level → same colour), a placeholder for per-level artwork later. The pit is drawn over a dark translucent overlay so the background never interferes with play. Block art uses a beveled 90s look; completed rows flash (inverse colours) before clearing.
| Mode | Players | Pit | Network |
|---|---|---|---|
| Campaign | 1 | 10×20 | — |
| Cooperative | 2 (local) | shared wide | — |
| Competitive | 2 (local) | shared wide | — |
| Dual Pit | 2 (local) | two pits, garbage sending | — |
| Head-to-Head | 2 | two pits, garbage sending | online host/join |
Campaign supports the original time-limit variants (Unlimited / 15 / 10 / 5 / 3 minutes) and both scoring systems (Original and Tetris Classic), plus the optional hidden-Next bonus.
./build.sh # builds the game client -> ./tetris
./tetris
./build-server.sh # builds the matchmaking server -> ./tetris-server
./tetris-server [port] # default port 7777Or directly:
odin build . -out:tetris -o:speed
odin build server -out:tetris-server -o:speedRequires the Odin toolchain (tested with the 2026-06 nightly). raylib and
core:net ship with Odin — no system libraries needed.
Release builds are attached to each tagged release: a .zip for Windows and a
.tar.gz for Linux/macOS (each also contains this README). Extract it, then:
Windows — the build is not code-signed, so Microsoft Defender SmartScreen shows "Windows protected your PC … unknown publisher." This is expected for an unsigned app, not a virus warning. To run it:
- Click More info → Run anyway on the SmartScreen dialog, or
- Right-click
tetris.exe→ Properties → tick Unblock → OK (then run), or in PowerShell:Unblock-File .\tetris.exe.
(Removing the warning entirely requires code-signing the executable with a trusted certificate — a paid step that isn't set up for these builds.)
Linux / macOS — extract and run ./tetris (the executable bit is preserved
in the tarball). On macOS, Gatekeeper may need Right-click → Open the first
time for the same unsigned-app reason.
Single player / online: by default all three schemes are active at once
(left- and right-handed friendly) — Arrows (↑ CW, Z CCW, ↓ drop),
IJKL (I CW, U CCW, J/L move, K drop), and WASD (W CW,
Left Shift CCW, A/D move, S drop). The Controls option in setup can
restrict it to a single scheme.
Two-player local — fixed schemes, one per side of the pit:
- Left player:
A/Dmove,Wrotate CW,Left Shiftrotate CCW,Sdrop. - Right player: Arrow keys or
J/I/K/L(+Ufor CCW) — both work, handy when a laptop's arrow keys are cramped.
Mouse (controls player 1): move the mouse left/right to slide the piece to that column, right-click to rotate, left-click to hard drop. Keyboard still works when the mouse is idle. Menus are mouse-clickable too.
Esc or P open the pause menu (Continue / Restart in solo & local modes /
Exit to Menu) · Enter confirm / return after game over.
M toggle music · N toggle sound effects.
Campaign shows a high score that persists between runs (saved locally to
~/.tetris-classic-highscore, or %APPDATA%\tetris-classic-highscore on
Windows) and flags a NEW HIGH SCORE! on the results screen when you beat it.
The HUD also shows a Pieces Used panel under the Next box with a running
count of each tetromino kind.
Music and sound are synthesized procedurally at runtime (a small chiptune
engine in audio/) — there are no audio asset files. The theme is an original
chiptune arrangement of Korobeiniki, the public-domain Russian folk melody;
its tempo rises with the level. Sound effects (rotate, drop, line clear, Tetris,
level up, game over) are short generated waveforms. Toggle either with M / N.
The down key is the only drop control; its behavior is set in options:
- Fast drop (default) — hold to fall faster, stops when released.
- Immediate drop — a press drops and locks the piece instantly.
Disabling on-screen aids scores more — bonuses stack:
- Next Piece off: +25%
- Ghost Piece off (the landing-shadow predictor): +10%
Set these (and the down-key behavior) on the Setup screen for local modes, or via Head-to-Head → Online → Options for networked play. They're personal and apply to your pit in every mode.
Two ways to connect, both under the Head-to-Head menu:
- One player picks Head-to-Head → Direct (LAN) → Host Game; the lobby shows
the host's LAN address (e.g.
192.168.1.18:7777) to read out. - The other picks Direct (LAN) → Join Game and types that
hostorhost:port. - Uses TCP port 7777. Over the internet this needs the host to port-forward; on a LAN/VPN it just works.
Run tetris-server somewhere both players can reach (a cloud VM), then:
- Both players pick Head-to-Head → Online (Server) and enter the server's address (IP or hostname).
- One picks Create Game, gives it a name, optionally a password, and chooses whether it's listed publicly (Browse) or join-by-name only.
- The other picks Browse Games (to pick from the list) or knows the name and joins it; if it's password-protected they're prompted for the password.
- They're matched and play begins.
All gameplay traffic relays through the server, so neither player needs to port-forward and it works behind any NAT. The server only needs its TCP port (default 7777) reachable. See Deployment below.
Each peer authoritatively simulates only its own pit and streams a snapshot to the other for display; line clears send garbage rows to the opponent, who applies them to their own pit. This is latency-tolerant — a late packet only briefly makes the opponent mirror stale, never desyncs your own game. The online server is a transparent relay: once matched, it forwards these same gameplay packets verbatim, so the protocol is identical to direct play.
The server is a single static-ish binary with no runtime dependencies beyond libc.
./build-server.sh # -> ./tetris-server
scp tetris-server user@your-vm: # copy to a cloud VM
ssh user@your-vm './tetris-server 7777'
# open TCP 7777 in the VM's firewall / security groupThen point both clients' server address at the VM's public IP or hostname. Lobbies are in-memory and ephemeral (no database, no accounts); restart clears them. For always-on use, run it under a process manager (systemd / a container).
A self-contained image is provided (Dockerfile at the repo root):
docker build -t tetris-server .
docker run --rm -p 7777:7777 tetris-server # or set the port:
docker run --rm -e PORT=9000 -p 9000:9000 tetris-serverSet the PORT env var to change the listen port (the image defaults it to
7777); a CLI arg also works when PORT is unset. The build downloads a prebuilt
Odin compiler matching the build architecture (amd64/arm64), pinned via the
ODIN_REF build arg — so it builds on x86 and ARM hosts (e.g. AWS Graviton)
alike. For local cross-arch builds use docker buildx --platform linux/arm64.
The root Dockerfile is what a Docker-native PaaS like
SnapDeploy builds: connect the GitHub repo, it detects
the Dockerfile, builds, and runs the server. The server reads $PORT if the host
injects one, and EXPOSE 7777 advertises the default.
HTTP health checks pass on the same port. The server sniffs each new
connection: an HTTP request (a platform health probe) gets a 200 OK, while the
game protocol is handled normally — so a single port serves both, no second port
or extra config needed.
Caveat — the game protocol is raw TCP. The health check now succeeds, but players still reach the server over raw TCP on the exposed port. That works if the host passes TCP through to the container; if the host only proxies HTTP to the public internet, players can't connect even though the deploy is healthy. In that case the transport would need to move to WebSocket (rides on HTTP/S) — a larger change not done here. A VM or TCP-capable host (Fly.io, a plain Docker host, etc.) runs it as-is.
.github/workflows/build.yml builds native client + server binaries for Linux,
Windows, and macOS (Apple Silicon) on every push, and attaches them to a
GitHub Release when you push a v* tag.
odin test game # board logic, scoring, garbage, seven-bag
odin test net # localhost direct host/join message round-trip
odin test server # in-process create/browse/join/match + gameplay relay
odin test audio # music data (bar sums), note frequencies, loop wrapmain.odin / app.odin / input.odin / menu.odin app state machine, loop, input
game/ core simulation (no rendering/net): types, board, scoring, session
render/ raylib drawing: palette, blocks, pits, HUD, layouts
audio/ procedural chiptune engine: synth, Korobeiniki arrangement, SFX
net/ client TCP transport + gameplay & lobby wire protocols (package `netplay`)
server/ dedicated matchmaking + relay server (separate binary, package `main`)
- Decorative per-level background art (original-style) behind the pits.
- Hold piece; full high-score table (per mode) beyond the single-player best.
- Richer arrangement / per-level music variations.
- WASM build target (raylib supports it) for browser play.