Snake, React-style, in your terminal
Jun 29, 2026 · 14 min read
Every terminal-UI library ships the same three demos: a counter, a spinner, a todo list. They're honest demos and they prove almost nothing, because they only ever re-render when the framework already knows something happened: a keypress, a timer the framework itself scheduled. A game has a world that advances whether or not you touch the keyboard, input that has to land between frames, state mutated from three directions at once, and a screen it wants entirely to itself. If React patterns in a terminal are going to crack anywhere, they crack here.
Snake is the hello world of game loops, so snake is what we'll build: the real examples/src/bin/snake.rs from the inkrs-examples crate in inkrs, the Rust port of Ink that the previous essays in this series took apart subsystem by subsystem. It's a port of upstream Ink's alternate-screen demo: one file, 432 lines, no game engine, no rand, no async runtime. This tutorial walks through it in the order you'd write it — board, state, steering, clock, food, screen — quoting the fragments that matter and telling you honestly what's summarized between them.
The board and the glyphs
The world is a 20×10 grid, and everything on it is a single ASCII character:
const BOARD_WIDTH: i32 = ;
const BOARD_HEIGHT: i32 = ;
const TICK_MS: u64 = ;
// Glyphs — chosen to be single-cell ASCII so the board lines up cleanly in
// every terminal. (Upstream Ink uses emoji which double up to two cells.)
const HEAD: char = '@';
const BODY: char = 'o';
const FOOD: char = '*';
const EMPTY: char = ' ';
That comment is carrying a whole essay on its back. A snake board is a grid of cells, and it only looks like a grid if every glyph occupies exactly one cell. Emoji don't: as the first essay in this series measured in detail, most emoji paint two cells, and the messier sequences paint a number the terminal ecosystem hasn't even agreed on. Put a 🍎 on a character grid and every column to its right shifts by one; your straight walls go jagged. Upstream Ink's demo uses emoji and wears the consequences. The port chooses @, o, * — one cell each, in every terminal ever made — and the geometry problem never arises.
Rendering a row is then just string building, done outside the markup so the view stays declarative:
And the view itself is rsx! — a bordered box, one text element per row, a status strip below. This is the React part of the promise, in Rust, via dioxus:
rsx!
Note width: (BOARD_WIDTH as u32) + 2: twenty cells of board plus two cells of border, because the border characters are part of the layout, not decoration painted over it.
Two layers of state
The first place a game diverges from a counter is state. The game state — snake segments, food position, direction, score, the over/won flags, and the RNG — lives in one plain struct:
But three different closures need to touch it: the render closure reads it every frame, the input handler steers it, and the main loop advances it once per tick. Rust doesn't let three closures mutably capture one value, so the state goes behind the classic shared-ownership pair:
let state: = new;
To be precise about why: it's not threading, mostly. The stdin reader thread only ships raw bytes over a channel; the input handler itself runs on the main thread, as we'll see. The Arc<Mutex<…>> is here because three owners need mutable access to one value, and this is Rust's standard way of spelling "shared, mutable, and we promise to take turns." Each closure takes the lock briefly, does its thing, releases.
The render closure starts by snapshotting under the lock, so the markup below never holds it:
let state_for_app = clone;
let app = move ||
This is the second layer: the mutex state gets mirrored into use_signal hooks. The peek-compare-set dance avoids writing the signal when nothing changed. And the source is refreshingly honest about how much of this is strictly necessary — its own comment says the runtime's tick() marks the root scope dirty anyway, so the closure re-runs and re-reads the mutex regardless; signals are simply the idiomatic dioxus way to surface changing values, and they'd become essential the moment a child component subscribed to them. The architecture in one sentence: the mutex is the truth, the signals are the view's memory of it.
Steering
Input is a use_input hook registered inside the render closure: it has to be, since hooks attach to the active dioxus scope:
let app_handle = use_app;
let state_for_input = clone;
use_input;
Spend one second appreciating key.up_arrow. What actually arrived on stdin was ESC [ A — three bytes of 1978-vintage VT100 convention, ambiguous with a bare Esc keypress, which is its own twenty-millisecond disambiguation story. An earlier essay spent its whole length turning those bytes back into keys; use_input is where all of that archaeology gets repaid as a bool you can if on. The handler reads like a description of the controls because everything hard already happened below it.
The one piece of game logic living here is the turn rule. A snake moving right that turns left would step onto its own neck and die instantly. Every snake implementation must refuse 180° reversals, and this one does it with an exhaustive matches!:
try_turn checks is_opposite (and no-op turns) before accepting a new direction. Steering is the only input that mutates the world; q and Esc go straight to app_handle.exit(), which just flips the exit flag the main loop is about to check.
The loop is yours
A browser hands React a requestAnimationFrame scheduler; upstream Ink hands you Node's event loop. inkrs hands you nothing — deliberately. As the porting retrospective put it, rendering stopped being something a framework schedules around you and became a function you call. So the game loop is a while in main, and the clock is a sleep:
let stdin = spawn;
// Game loop. `pump_stdin` re-renders on every keypress; the explicit
// `tick()` below makes sure we still repaint when no input arrives so
// the board reflects each advance.
while !inst.is_exited
Two runtime calls carry the whole cadence, and their contracts (from inkrs-runtime's Instance) are worth stating precisely:
pump_stdin(&stdin)drains whatever bytes the reader thread has buffered in the channel — without blocking — and pushes them through the input pipeline, which is what ultimately invokes theuse_inputhandler above, on this thread. Empty channel, no work: the frame isn't redrawn for nothing.tick()marks the root scope dirty, re-runs the component closure on the persistent VirtualDom, and pushes the result through the incremental renderer — the line-diffing machinery a previous essay dissected, which repaints only the rows the snake actually crossed. Because the VirtualDom persists across ticks, hook state survives; because the root is marked dirty, external state like our mutex is re-read every time.
So each 200ms turn of the loop is: absorb input, advance the world one cell, repaint. The runtime is event-loop-agnostic; it doesn't know 200ms is the tempo, and nothing stops you from speeding up as the score climbs. You own the clock. The port is designed around it.
Food without rand
Placing food needs randomness, and an example binary has no business pulling in a dependency tree for it. The file brings its own generator: a linear congruential generator with the constants everyone copies from Numerical Recipes:
/// Numerical-Recipes-style 32-bit LCG. Quality is fine for placing food on
/// a 200-cell board; we don't need cryptographic randomness here.
It's seeded from SystemTime nanoseconds (| 1 so it's never zero), and the >> 16 matters: an LCG's low bits are its weakest — the bottom bit of this one just alternates — so you take from the top. It's a bad RNG and a sufficient one: the only consumer is next_range(bound) = next_u32() % bound with bounds of 20 and 10, where even the modulo bias amounts to a few parts per billion. Know what your randomness is for; food placement on a 200-cell board is not key generation.
Placement itself is rejection sampling — roll a cell, reroll if the snake is on it:
The loop looks unbounded; in practice, with the snake well shy of filling the board, it converges on the first or second roll. And the one game state where it would spin forever — a snake covering all 200 cells — is exactly the state the step function declares a win before ever asking for food, as we'll see below.
An alternate screen you must give back
A game wants the whole terminal. It should not get the user's scrollback: whatever was on screen before the game started — build output, notes, the command that launched it — belongs to the user, and a game that scrolls it into oblivion is rude. Terminals solved this decades ago with the alternate screen buffer, a second page the application flips to and back from. The setup, in order:
let mut inst = new;
// Put the terminal in raw mode *before* entering the alt screen, so the
// arrow keys, `r`, `q` and Esc arrive byte-by-byte immediately. Without
// this, stdin stays canonical (line-buffered): the snake walks itself
// into a wall and you can't steer or quit until you press Enter.
// `.ok()` keeps piped (non-TTY) input working; the guard restores on Drop.
let _raw_mode = enable.ok;
inst.enter_alternate_screen;
enter_alternate_screen writes the DEC private-mode sequence \x1b[?1049h plus a cursor-home, and — a detail that's easy to get wrong when hand-rolling this — resets the incremental renderer's memory. The diff renderer believes the previously painted frame is still on screen; after a buffer switch that's false (the alt screen starts blank), and without the reset it would repaint only the lines that changed, leaving most of the board invisible until the snake happened to slither through it. Buffer switches invalidate frame memory; the runtime knows.
The teardown is the mirror image, and it is not optional:
// Restore the user's primary buffer before tearing the runtime down.
inst.exit_alternate_screen;
inst.unmount;
stdin.stop;
exit_alternate_screen writes \x1b[?1049l and the user's scrollback reappears, untouched, exactly as they left it; the raw-mode guard restores cooked mode when it drops; stop() joins the stdin reader thread. The user's terminal is on loan — and if that teardown obligation sounds like it deserves crash-proofing rather than good intentions, the panic essay covered what the runtime does when your code doesn't get to run its last three lines.
Game over, restart, run it
The step function, GameState::advance, is where the game's rules actually live: compute the new head from the current direction, then check the two ways to die. Walls first, then self-collision — and the self-collision check hides the subtlest line in the file:
let ate = new_head == self.food;
// When we *don't* eat, the tail moves away this tick, so the cell
// it's leaving is free game. Mirrors upstream's `slice(0, -1)`.
let collision_body: & = if ate else ;
if collision_body.contains
Movement is simultaneous: as the head enters a cell, the tail vacates one. So chasing your own tail into the cell it's leaving this very tick is legal — unless you just ate, because then the tail stays put to make the snake grow. Checking against the full body unconditionally would kill the player for a move the game's own physics permits. Upstream Ink's reducer encodes it as slice(0, -1); the port encodes it as a slice minus its last element; both are the same rule: collide with the body as it will be, not as it was.
Past the checks, the head is prepended; no food means the tail pops (motion), food means the score bumps and — the win condition — if the snake now covers all BOARD_WIDTH * BOARD_HEIGHT cells, the game sets over and won instead of asking random_empty_cell to find a cell that doesn't exist. Filling the board ends as a victory instead of a hang.
Game over deliberately does not exit the loop. The world just stops advancing (advance returns immediately once over is set), the status line switches to "Game over! r: restart, q: quit", and the loop keeps pumping input. Pressing r calls reset(), which rebuilds the snake and score in place but keeps the existing RNG — so the food doesn't respawn in the same spot every round. Restart is nothing more than a state change; the React framing pays off one last time here, because the view needs no special case — it just renders whatever the state says.
Run it (from the inkrs repo, per examples/README.md — it needs a real TTY, since the stdin reader blocks on actual bytes):
Arrows steer, r restarts, q or Esc quits — and when you quit, look at your terminal: everything you had on screen before the game is still there. That's the alternate screen keeping its promise.
The whole game
We've quoted maybe a third of the file; the full 432 lines are examples/src/bin/snake.rs in inkrs, and they contain nothing this walkthrough hasn't named: the remaining two thirds are the plumbing between the fragments — Direction::offset, the initial three-segment snake, the signal mirroring for length, the status strings. One file, no engine, no rand, no scheduler.
Look at what the game actually leans on, though, and this essay is just the top of a stack this series already climbed: cell-width discipline chose the glyphs, keypress parsing turned ESC [ A into key.up_arrow, the diff renderer repaints only the rows the snake crossed, and the raw-mode guard gives the terminal back even when things go wrong. A counter demo exercises one of those layers at a time. Snake needs all of them at once, every 200 milliseconds, and doesn't drop a frame.
React promised that a UI is a function of state. Turns out that holds even when the state is a snake, the DOM is a character grid, and the event loop is a while with a sleep in it.
Thoughts? Find me on LinkedIn.