skip to content
Linell Bonnette

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:

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.com connects 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: -local skips 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.