TetriSSH
Tetris over SSH (or Tetris over HTML over SSH)
It’s no secret that terminal user interfaces (TUIs) are growing in popularity as a result of the explosion of TUI-based tools a la Claude Code. I’ve been a fan of the command line for quite a while now and could not be more excited! Being able to do more from my terminal and it being pretty? Sign me up!
When I first came across BubbleTea I knew that it was neat, but the more I learned about it the cooler I realized it was! One of the most intersting aspects was that you could theoretically create a TUI that’s served via a webpage? I’d seen it done a couple of times, but decided that to really understand what was happening I needed to implement it myself.
Preview
You can visit the webpage, ssh tetris.thelinell.com, or just watch this to see what it looks like in action:
Tech stack
TetriSSH is written in Go and built on Charm’s libraries:
- Bubble Tea v2 for the TUI framework
- Wish v2 for the SSH server middleware
- Lip Gloss v2 for styling
- Harmonica for the spring/projectile animations
- charmbracelet/ssh and charmbracelet/x/xpty underneath the server and PTY handling
The browser bridge in internal/web is adapted from btwiuse/boba, which also supplied coder/ghostty-web, the terminal emulator running client-side. It uses coder/websocket for the WebSocket transport.
How it works
One Bubble Tea model drives all three ways of playing:
- SSH:
ssh tetris.thelinell.comconnects to a Wish server, which hands each session its own Bubble Tea program directly — no shell, no exec, no port forwarding. - Browser: the same binary runs a small HTTP/WebSocket server. Visiting the page opens a WebSocket to
/ws, which spins up a server-side PTY and runs the identical Bubble Tea program against it. Output is streamed to the page as binary WebSocket frames and rendered by ghostty-web (compiled to WASM); keystrokes go back over the same socket. A tiny framing protocol (internal/web/protocol.go) tags each frame as input, output, resize, or close. - Local:
-localskips the SSH/HTTP layer entirely and runs the Bubble Tea program directly in the current terminal, for a fast dev loop.
Game logic lives in internal/game, decoupled from Bubble Tea entirely (no imports of it), which makes it unit-testable in isolation:
- Standard 10×22 board (2 hidden rows above 20 visible), SRS-style rotation with wall kicks per the Tetris Guideline
- 7-bag randomizer (shuffle one of each piece, deal, then refill) for the piece sequence
- Ghost piece, lock delay, soft/hard drop scoring, and a level system that speeds up gravity every 10 lines cleared
The TUI layer (internal/tui) adds the presentation on top: an animated splash screen, screen shake and dust particles driven by Harmonica springs/projectiles, a strobing line-clear animation with the stack collapsing into the gap, and a shared high-score table (which is backed by a plain old JSON file).
Deployment
TetriSSH compiles to a single static binary, deployed to a small cloud VM under systemd. A few things made it feel reasonably safe to expose to the open internet with no auth:
- The game itself binds port 22, so anyone hitting the standard SSH port just gets handed a Bubble Tea program — no shell, no exec, nothing to break out into.
- The systemd unit runs as an unprivileged, otherwise-locked-down user:
ProtectSystem=strict, a minimal capability set, and a memory/task cap, so even the game process itself can’t touch much beyond its one data file. - Caddy fronts the browser path with automatic TLS, and is the only thing reachable on the standard web ports.
- Sessions time out after a fixed idle period and a hard maximum, so abandoned connections can’t accumulate and exhaust resources.