A terminal arcade. Classic games rendered at sub-cell resolution with block
pixels, built on Bubble Tea. Every game is a
sandboxed WebAssembly package — including the three that ship with it —
and anyone writes them against the SDK and publishes them for players to
termcade add.
Three games are bundled, unpacked into your games directory the first time the arcade runs:
| Game | What it is |
|---|---|
| asteroid | Asteroids-style rock shooter |
| tetris | falling-block stacker |
| brickough | Breakout-style brick breaker |
They are ordinary .tcade packages running in the same sandbox as anything
you install, built from source in
termcade-games and vendored
here as packages. Nothing is compiled into the binary as a game.
Uninstall one and it stays uninstalled — the arcade seeds a game once, not every run. The TUI opens directly on Marketplace; Library includes installed games even while signed out.
With Go installed, straight from the internet:
go run github.com/aviorstudio/termcade@latestOr grab a prebuilt archive from the
releases. From a
checkout (toolchain pinned via mise — mise install): go run ..
Needs a truecolor terminal at 80×24 or larger.
TERMCADE_PIXELS picks how each terminal cell is subdivided. Every setting
draws the same playfield into the same cells; only sharpness and font
requirements change.
| Value | Pixels per cell | Notes |
|---|---|---|
quad (default) |
2×2 | Unicode 1.0 block elements; renders anywhere. |
sextant |
2×3 | Sharpest, near-square pixels. Needs Unicode 13 (Symbols for Legacy Computing) or you get tofu. |
half |
1×2 | The original look, for fonts that struggle with the rest. |
ascii |
3×3 | Pure ASCII art: a density ramp plus line characters. No block glyphs at all, so it works in any font (and any 1980s sensibility). |
TERMCADE_PIXELS=sextant go run .In the arcade, the pixel-style control in Settings or the pause menu cycles
the style for the next game start and persists it to
~/.config/termcade/settings.json; TERMCADE_PIXELS
overrides the saved choice for a run. Games never change for any of this —
the renderer owns the look.
Terminals send no key-release events, so a held key is normally guessed from auto-repeat — which stalls for the OS repeat delay (often ~500ms) after the first press, and makes the paddle feel like it ignores you.
termcade asks for the Kitty keyboard protocol's event-type reporting. On terminals that support it (kitty, Ghostty, foot, WezTerm, recent xterm) held keys are exact and that stall disappears. Elsewhere it silently falls back to the auto-repeat heuristic.
| Key | Action |
|---|---|
| Tab / Shift+Tab | focus navigation, page content, or actions; move between form controls |
| ↑/↓ | select a row or pause-menu item |
| ←/→ (actions) | select the focused action |
| enter | activate a focused item/button; ordinary game rows open details |
| Page Up / Page Down | page lists or scroll long detail/documentation text |
| ←/→ (or a/d, h/l) | move paddle / turn ship / shift piece |
| ↑ (or w) | thrust (Asteroid) · rotate (Tetris) |
| ↓ (or s) | soft drop (Tetris) |
| space or z | A button: launch ball / fire / hard drop |
| x | B button |
| esc or p | pause |
| esc (outside gameplay) | dismiss/back; stop waiting and reconcile a pending form mutation |
| Ctrl+C | quit and save local scores |
While a game runs, game inputs cannot operate the sidebar. Pause first, then Tab into navigation. The sidebar is 22 columns wide at 120+ terminal columns; below that it overlays the content on demand. Non-game content is capped at 100 columns. Gameplay keeps its fixed cell footprint and hides the sidebar when needed to fit. See TUI workflows for the complete contract.
High scores persist to ~/.config/termcade/scores.json, and are yours whether
or not you have an account — see Your history.
The TUI opens on Marketplace and shares the web app's Marketplace, Docs, Library, owner/game pages, and account/settings destinations. Browsing is anonymous. Library joins account games with installed packages, with clear installation/membership badges and Continue Playing entries.
In the TUI, Add saves account membership without downloading. Play installs a missing compatible account game; healthy installed games remain playable offline. Remove from Library changes only account membership; Uninstall here removes only the local package, after confirmation. No automatic updates replace an existing local copy.
Sign in from Settings without exiting the TUI: it shows the trusted pairing URL
and one-time code, and opens a browser only when you choose Open browser.
Account authentication stays in the browser. Settings also supports handles,
org/member administration, CLI-session revocation, account deletion, and
revoke-then-clear sign-out. The normal pairing destination remains
https://app.termca.de/pair.
The gate is a product decision, not a security boundary: packages are public GitHub release assets, so an account is not what keeps anyone out. What it does is give account games a library that follows you between devices.
Existing shell commands keep their established behavior: termcade add
saves membership and installs; termcade remove removes membership and the local
copy; termcade login restores missing account packages; termcade sync adds
missing packages without deleting local-only games or automatically updating
existing copies. The new TUI's separate actions do not silently change those
command contracts.
Creating an account in the app claims a username — your publishing handle, and the author
segment of every game you release. nicodes/pong and aviorstudio/tetris are
the same kind of name: the second belongs to an org, which is a studio more
than one person can publish under. Being a member is enough to publish; admin
governs the studio itself.
The same works from the command line:
termcade login # approve this CLI in your browser
termcade add aviorstudio/brickough # add straight from the marketplace
termcade dev install <file>.tcade # install a local build while developing
termcade whoami # who you are, and what you publish as
termcade username <name> # claim, rename, or check one is free
termcade org new aviorstudio # a studio to publish under
termcade sync # bring your library to this machine
termcade keys new ci <username> # a key for a release workflow
termcade list # what's here
termcade remove author/slug # take one off (updates your library too)Your high scores live in scores.json and always have. The arcade plays with
no account and no network, and a score set that way is not waiting on either.
What an account adds is that the same history follows you. Every finished run is recorded locally and queued; the queue drains when there is a session and a network, which may be now, may be after you sign in, and may be next week. On another machine, signing in brings the account's side down. Both directions only ever raise a value — a high set here and a higher one set there both survive — so the two can disagree, sync in either order, or never sync, and the worst outcome is that a score stays where it was set.
The library also marks a game the marketplace has moved past, comparing the
version in the installed package's own manifest with what the catalog
publishes. termcade add again is what updates it.
These are personal records, not a leaderboard. A score comes from a game running in your own sandbox, and nothing on the other end can check it. It is your history, shown back to you; ranking anybody by it would need evidence that does not exist yet, so nothing here pretends to.
There is no way to install an older version, and that is deliberate. A game
is not a dependency — nothing builds against one — so the reasons package
managers pin (reproducible builds, lockfiles, a transitive bump breaking you)
do not apply. add gives you what the author currently ships. The one thing
that does narrow the choice is the ABI: the registry picks the newest release
your arcade can actually run, so a game that has moved on to a later ABI tells
you to update termcade instead of handing you a package the host would refuse.
The registry is https://api.termca.de. TERMCADE_REGISTRY overrides it,
which is how you reach a local termcade-be stack (make dev serves one on
port 8080):
TERMCADE_REGISTRY=http://127.0.0.1:8080 termcade add you/mygameThe registry stores no packages. It is an index: a game's releases live on its GitHub releases, and the registry records where each one is and what it hashed to when it fetched and validated them. Publishing is open source only for now — the registry checks that a repository is publicly visible before it will serve anything from it.
Packages come back through the API rather than from GitHub directly. That is
what lets an install require an account and lets the arcade and the app share
one path. termcade add asks the registry which release to install, streams
it from the registry, and refuses to install anything whose sha256 does not
match what the registry recorded — so a release asset swapped after publishing
fails rather than reaching a player.
Installed games are WebAssembly modules that run sandboxed (no filesystem, no network) under wazero. A broken install shows up dimmed on the menu with the reason; it can never take the arcade down.
The arcade you play with is also the whole dev kit — termcade dev new you/mygame scaffolds a playable game, termcade dev build packages it, and
termcade dev install build/mygame.tcade puts it on your own menu.
To put it on everyone else's, attach the .tcade to a GitHub release and tell
the marketplace where it is:
termcade publish https://github.com/you/mygame v1.0.0 mygame.tcadeThe registry fetches that asset once, validates it against the same manifest
rules the arcade enforces, reads the game's identity and version out of the
manifest inside it, and records its digest. Nothing you pass here asserts
what the package is — only where it is. The author segment of the id must be
a handle you already control: your username, claimed at signup or with
termcade username, or an org you belong to. Publishing under a name nobody
has claimed is refused rather than minting it.
A game is a Go package implementing sdk.Game — see
docs/sdk.md for the author quickstart,
docs/packaging.md for the manifest and .tcade format,
and docs/abi.md for the frozen wasm ABI (for non-Go
toolchains). termcade dev build turns a game directory into an installable
package.
termcade-games is three
worked examples — complete games depending only on the sdk module, each
buildable into a .tcade via its cmd/wasm entrypoint, each published to
the marketplace the way yours will be.
sdk/— the public, stdlib-only module game authors depend on: theGamecontract (fixed 60 TPS ticks, structured HUD, 8-key input), pixel canvas (pluggable cell shapes, run-length ANSI batching), vector math, Bresenham rasterization, held-key tracking, andsdk/tcgame, the wasm-export glue. Games draw in square logical units and never see the pixel grid.internal/engine— host-side registry types andSafeGame, which contains any game panic to a crash screen instead of a dead arcade.internal/plugin— the wazero host: sandboxed instantiation, per-call watchdog deadlines, install-dir discovery.manifest/—termcade.tomlparsing/validation and the.tcadezip format. Public rather than internal: the registry validates uploads against this same package, so a game the marketplace accepts and a game the arcade will run are decided by one piece of code.internal/shell— the Bubble Tea arcade frame: menu, 60 TPS tick loop that steps off the measured clock rather than drifting, pause/game-over/crash overlays.internal/scores— atomic JSON high-score persistence, namespaced byauthor/slug.
go build -o bin/termcade . # local builds land in bin/ (gitignored)
go test ./... # unit + shell tests
go test ./internal/plugin/ # includes wasm end-to-end tests (builds guests)
go vet ./...