A terminal love letter to Chrome's offline T-Rex runner — written in Rust on top of crossterm, drawn with Unicode block characters, played wherever your shell lives.
cargo run --releaseStatus: developed and tested on macOS only. crossterm itself supports Linux and Windows (modern Windows Terminal), and the code uses no platform-specific calls, but the rendering and key handling have not been verified there. If you run it on something other than macOS, please open an issue with what you saw.
I'm an aficionado of retro arcade-y things. The Chrome dino is one of those rare tiny games that's perfect on its purpose: pure timing, no scoreboard nags, no power-ups, no shop. It exists where you accidentally meet it (an offline error page) and asks nothing of you except attention.
This is my homage. Same idea, but it doesn't need your wifi to be down — just a terminal. Built mostly because I wanted to see how far you can push terminal rendering before it stops feeling like ASCII art and starts feeling like a tiny game.
The original Chrome dino was built in 2014 by Google's Chrome UX team in Mountain View — mainly Sebastien Gabriel, with Alan Bettes and Edward Jung. The internal codename was Project Bolan, after Marc Bolan, frontman of the glam-rock band T. Rex. The dinosaur itself was a wink: being offline meant living "in the stone age".
At launch the game only had cacti to dodge. Pterodactyls (which force you to duck) and the night-mode toggle (which inverts the palette after a few thousand points) came in later iterations. Google has estimated something like 270 million plays per month, mostly on mobile devices in regions with unreliable connectivity — which probably makes it one of the most-played games most people have never thought of as a "real" game.
You can still play the original at chrome://dino in any Chrome browser, online or off.
All three views below show the same 80×24 terminal layout. White background and dark foreground are forced by the renderer (Chrome-dino convention) so the game looks the same regardless of your terminal theme.
Title screen — current score and HI sit in the top-right corner; on first launch both are zero. The dino stands idle behind the overlay.
00000 HI 00000
___ _____
_( )_ _( )_
(_______) ( _________)
█████ █████ █ █ ████ █████ █ █
█ █ █ █ █ █ █ █ █
█ █ █ ███ ████ ████ █
█ █ █ █ █ █ █ █
█ █ █ █ █ █████ █ █
Press SPACE to start
← RELAXED [ STANDARD ] BRUTAL →
SPACE/↑ jump ↓ duck ← → difficulty Q quit
████
██▀▀
▄ ███▀
▀█████▀
▀ █
────────────────────────────────────────────────────────────────────────────────
· · · · ·
In-game — the run-history column lives under HI: LAST is the most recent finished round, older runs stack chronologically below. Two pterodactyls in flight (one HIGH, one LOW), a large cactus on the ground, the dino mid-stride.
01234 HI 04567
LAST 03210
02890
03100
01450
02080
00940
___
_( )_
(_______)
▙ ▟
▀▀
████ ▄ ▄
██▀▀ ▀██▀ █
▄ ███▀ █▄█▄█
▀█████▀ ▀█▀
▀ █ █
────────────────────────────────────────────────────────────────────────────────
· · · · ·
Game over — no difficulty selector here; the locked difficulty keeps the high-score honest. The run that just ended is already recorded as LAST. Dead-dino has an x for an eye and a frozen-leg pose. The cactus that killed it sits right next to the dino's body.
03812 HI 04567
LAST 03812
03210
02890
03100
01450
02080
█████ █████ █ █ █████ █████ █ █ █████ ████
█ █ █ ██ ██ █ █ █ █ █ █ █ █
█ ██ █████ █ █ █ ████ █ █ █ █ ████ ████
█ █ █ █ █ █ █ █ █ █ █ █ █ █
█████ █ █ █ █ █████ █████ █ █████ █ █
SPACE restart Q quit
████ █
██▀▀ █▄█▄█
▄ ██x▀ ▀█▀
▀█████▀ █
▀█▀█
────────────────────────────────────────────────────────────────────────────────
· · · · ·
git clone https://github.com/<YOUR-USERNAME>/tty-rex
cd tty-rex
cargo build --release
./target/release/tty-rexYou need Rust (any 2021-edition-capable toolchain, tested on stable). The only runtime dependency is crossterm 0.28.
tty-rex # Standard difficulty
tty-rex relaxed # easy mode
tty-rex brutal # heavy gravity, fast world
tty-rex --difficulty standard # explicit flag form
tty-rex -d brutal
tty-rex --help| Key | Action |
|---|---|
Space / ↑ |
jump |
↓ |
duck under low birds (or fast-fall while airborne) |
← → |
change difficulty (only on the title screen) |
Space / R |
restart after a game over |
Q / Esc |
quit |
| Stat | Relaxed | Standard | Brutal |
|---|---|---|---|
| Gravity (cells/s²) | 90 | 130 | 170 |
| Jump velocity | -38 | -42 | -46 |
| Initial speed | 22 | 32 | 40 |
| Acceleration | 0.4 | 0.7 | 1.1 |
| Max speed | 55 | 80 | 110 |
The chosen difficulty is locked once the first game starts, so the high score stays comparable across rounds in the same session. To switch later, restart the binary.
- Small cactus (3 cells tall) — jump.
- Large cactus (4 cells tall, with arms) — jump higher / earlier.
- Low pterodactyl (dark blue, head height) — duck. Unlocks after score 50.
- High pterodactyl (dark magenta, above the dino) — don't jump under it. Mid-flight body is high enough to clip your jump arc.
Birds bob up and down on a sine wave; the visual offset is render-only, the hitbox stays put.
A single-file Rust program, ~1300 lines including 16 unit tests. Architecture is intentionally flat — Game struct holds all mutable state, render and update are methods on it, no event bus, no ECS, no abstraction layers. For a game this size that's the right call; the moment you'd add a second screen or a multiplayer mode it'd start hurting.
Collision math. Started with hand-tuned AABB insets ("shrink the bbox by 1 column on each side for fairness"). It worked until it didn't: the large cactus's arms sit on the outermost columns of the sprite, and the inset silently excluded them. Players could clip through cactus arms at a precise frame timing and not die. Fix: throw away the heuristics and compute a tight bounding box from the actual non-whitespace cells of the current sprite. Now the bbox is the visible silhouette. See tight_bbox() and the regression test large_cactus_arm_at_dino_right_edge_collides.
Duck-end snap. When the duck timer expires, the dino has to "stand up" — its dino_y jumps from ground_y - 3 (duck height) up to ground_y - 5 (run height) in a single frame. A naive snap (if dino_y >= ground_top { snap }) also fired when the player jumped out of a duck pose, because the negative vy of the jump impulse ran through the else branch, the snap saw a dino_y "below" the running ground top, and zeroed vy. Effect: the jump silently failed. Fix: gate the snap on vy >= 0.0 so it only triggers when the dino is descending or at rest.
Key event kinds across platforms. crossterm normalises key events into Press, Repeat, and Release variants, but what your terminal actually emits varies a lot:
- macOS / Linux without kitty-keyboard-protocol enhancements: only
Press. Holding a key produces a stream ofPressevents at the terminal's auto-repeat rate. - Windows console (modern Windows Terminal):
PressANDReleasefor every key, by default. A naïve handler that reacts to "any key event" would calljump()twice per tap.
The input loop guards against both with a single match arm:
Event::Key(KeyEvent { code, modifiers, kind, .. })
if matches!(kind, KeyEventKind::Press | KeyEventKind::Repeat) => { ... }Release events are filtered out by the guard, so each player keypress drives exactly one game action regardless of platform.
Ducking without release events. Since Release isn't reliable everywhere, ducking is timer-based instead of edge-triggered. Each Down keypress (auto-repeated by the terminal while held) refreshes a duck_until 0.6 s into the future; when the player releases, no more events arrive, the timer expires, and ducking ends. Brittle on terminals with very slow auto-repeat, but pragmatic.
Terminal background. Originally I let the user's terminal theme show through. Then somebody with a white terminal could not read the white-on-white score. Fix: force a white background and use only dark ANSI foreground colors for everything in the play field. Looks identical to Chrome dino regardless of the user's profile. ANSI colors only — Color::Rgb would have looked nicer but support varies wildly across emulators.
- Per-cell rendering with a backbuffer. Right now every render frame queues
MoveTo+Printcalls directly. A 2D char+attr buffer with a flush step would make sprite layering trivial and enable diff-based output. For this game's complexity it would have been overkill, but it's the natural next step if you want particle effects, day-night transitions, or a second player. - Tests from day one. I added unit tests for
check_collisiononly after shipping bugs. Should have started with them — collision math is the kind of code where table-driven tests are nearly free and catch every regression instantly. - Sprite definitions as
&[(char, fg)]instead of&strarrays. Would let me color the dino's eye separately from its body, mark "transparent" cells explicitly instead of hacking with spaces, and skip the runtime tight-bbox calculation.
- Unicode width assumptions. Block elements (
▄,▀,█,▙,▟) and arrows (←,→,↑,↓) are all assumed to be exactly one terminal cell wide. They are in every modern monospace font I tested, but a font without them substituted via fallback can break alignment. - Frame pacing. A simple
sleep(remaining)works on local TTYs but stutters in some hosted terminal multiplexers. If you see jitter, look at theFRAME_DURATIONloop inrun(). - Resize during a game. Handled, but the cacti currently in flight don't reposition relative to the new ground line — they stay glued to the old
ground_yuntil they scroll off. Fine in practice, surprising in code review.
.
├── Cargo.toml
├── Cargo.lock
├── README.md
└── src/
└── main.rs ← everything lives here
No license file yet — add one before publishing if you intend others to fork or redistribute.
- Original Chrome dino: Sebastien Gabriel, Alan Bettes, Edward Jung at Google.
- Marc Bolan, in absentia, for the codename.
crossterm— the cross-platform terminal-manipulation crate that does all the heavy lifting (raw mode, alternate screen, ANSI sequences, key events, the lot).
Built with ♥, supported by Claude.