10 years ago I built it as a small experiment to create tetris with minimal, clean, functional code built around redux. I recently updated it both typescript and rust using Cursor. It's been interesting re-reading it vs a lot of the llm generated code that I work with nowadays.
LLMs are pretty good at getting the thing done. They are much less good at one-shotting beautiful code with clean abstractions. You can use them to produce this kind of code, but usually only with a lot of guidance and taste applied afterwards.
Does any of this matter? Maybe not that much. This was just an experiment I put together while on holiday about ten years ago, and I still find it intellectually satisfying. But the more code we have to read, the more the right abstractions matter. Strong types and immutability matter more too, not less. Unfortunately without strong guidance, most LLM coding agents will add fallback upon fallback rather than fixing an abstraction, or introducing a stronger type at the boundary.
Anyway before I go throough the code, here is the final result.
Clean, simple actions
Every input arrives as a plain action:
export const tick = createAction(ActionType.TICK);
export const moveLeft = createAction(ActionType.LEFT);
export const rotate = createAction(ActionType.ROTATE);
Modelling the game "tick" as an action helped keep all state in a clean functional redux reducer.
The reducer does not know about keyboards, timers, or React; it just receives domain events. That keeps the game logic in a pure core instead of scattering it through event handlers and components.
Shapes are plain data
The tetrominoes are just data:
const T = {
color: "purple",
shape: [0, 0, 0, 1, 1, 1, 0, 1, 0],
};
There are no classes here, no behaviour hidden inside pieces. The shapes are small enough to inspect directly. Later, cells are normalised into { val, idx } pairs so rotation and collision logic can operate on one regular structure.
In my experience modeling as much as possible as a data structure really helps with system extensibility and robustness.
Movement is small transforms plus one guard
The movement handlers are intentionally tiny:
const handleMoveLeft = R.over(blockXLens, R.add(-1));
const handleRotate = R.over(blockShapeLens, rotateBlock);
On their own, those functions are almost too small to mention. The interesting part is the wrapper around them:
const guardState = (fn) => (state) => {
const newState = fn(state);
return isValid(newState.board, newState.block) ? newState : state;
};
Then the reducer wiring stays clean:
export const reducer = createReducer(initialState, {
[ActionType.LEFT]: guardState(handleMoveLeft),
[ActionType.RIGHT]: guardState(handleMoveRight),
[ActionType.DOWN]: guardState(handleMoveDown),
[ActionType.ROTATE]: guardState(handleRotate),
// ...
});
This is probably the abstraction I still like most in the codebase. "Apply a transform, then roll it back if it creates an illegal state" is a real rule in the domain, so it deserves a name. Once it has one, every movement rule inherits the same semantics without repeating the same checks.
The main loop is a pipeline
The tick handler was the whole point of the exercise. On each TICK, several things may happen, but not all on every frame:
[ActionType.TICK]: [
skipOnPaused(incrementGameCounter),
slow(skipIfEmptyBlock(moveBlocks)),
slow(clearRows),
slow(markRows),
slow(runIfEmptyBlock(addBlock)),
slow(incrementScore),
],
I like self-describing, readable code like this - again an LLM will never produce this kind of code without a lot of guidance!
skipOnPausedstops the counter advancing when the game is paused.slow(handler)gates parts of the pipeline so they only run every Nth tick.skipIfEmptyBlockandrunIfEmptyBlockdecide whether a live piece exists or whether the game should spawn one.
The order matters. Rows are marked before they are cleared. A new block only appears after the previous state has settled. Scoring comes after the board update. I liked being able to express that as a sequence of transformations instead of a big phase switch with state threaded manually through each branch.
It is not the most idiomatic Redux style, and that is fine. The point was to make the rules readable as a composition of small functions.
Rendering is super simple
The UI layer is separate enough that it barely counts as game logic. React just projects the current state into display rows:
const displayRows = useMemo(
() => R.splitEvery(BOARD_WIDTH, mergeBoardAndBlock(state)),
[state],
);
mergeBoardAndBlock is a pure function that gives a grid that we render out:
<div className="board" role="grid" aria-label="Tetris board">
{rows.map((row, rowIndex) => (
<div key={rowIndex} className="board-row" role="row">
{row.map((cell, colIndex) => (
<div
key={colIndex}
className="cell"
role="gridcell"
style={{
background: cellColor(cell),
width: CELL_SIZE,
height: CELL_SIZE,
}}
/>
))}
</div>
))}
</div>
I could have rendered as canvas, but in this example I just did in html. It's a simple enough grid and as you can see not much code at all!
There is no concept of a tetromino at the component level. All React sees are cells on a grid.
Rust?
I've been doing a lot with Rust recently. Sometimes for the low level speed, but more often than not for the strong type system and simple comoilation to WASM.
I think moving complex logic to Rust and having the discipline of a strict JS/WASM boundary can really help with complex SPAs - see article here: Make TypeScript Boring.
Already in the code above I've shown how pushing logic away from components can result in clean, readable logic.
With Rust I take that a step further by making the TS layer super dumb:
- renders a view model from Rust
- emits events
That's it.
While there are a lot of frameworks to do the full UI in Rust - I think this is unneccesary. If I'm honest the UI in Zed just doesn't feel as polished as Cursor or VS Code. The key reason I think is because Zed has a completely custom UI framework rather than re-using html/css.
Anyway this is what the code looks like in Rust:
pub fn dispatch(&mut self, input: &WorkerInput) -> WorkerOutput {
match input {
WorkerInput::Init { seed } => {
self.state = add_block(AppState::with_seed(*seed));
self.view_model = select_view_model(&self.state);
WorkerOutput::Initialized {
view_model: self.view_model.clone(),
effects: startup_effects(),
}
}
WorkerInput::Event { event } => {
let prev_vm = self.view_model.clone();
let transition = reduce(&mut self.state, event);
self.view_model = select_view_model(&self.state);
let patches = diff_serializable_checked(&prev_vm, &self.view_model);
WorkerOutput::Response {
patches,
effects: transition.effects,
diagnostics: vec![],
}
}
}
}
That is a nicer boundary than the original project had. Inputs come in as a typed protocol. State changes happen inside the engine. Rendering consumes a serialisable ViewModel. The host only needs to apply patches and run effects like timers.
The tick pipeline is still recognisably the same:
state
.pipe(increment_counter)
.pipe(slow(skip_if_empty_block(move_blocks)))
.pipe(slow(clear_rows))
.pipe(slow(mark_rows))
.pipe(slow(run_if_empty_block(add_block)))
.pipe(slow(when(|s| !s.gameover, increment_score)))
This may not be idiomatic Rust - but again it is intellectually satisfying, and sometimes that is the point!
The rust code is here: https://github.com/davidgtonge/rust-tetris.
It's over-engineered for a simple tetris game, i.e. there is no real need to use CBOR. However in other codebases the reduced serialisation cost of CBOR really helps.
So what
If you've read this far, thank you. Hopefully you found some of the code ideas interesting and hopefully you've been inspired that with the right abstractions, code can be beautiful.