Building the Terminal: A Fake OS as Portfolio Easter Egg
Why I built a fake terminal on my portfolio site, the architecture behind 57KB of React terminal madness, and what I learned shipping games, themes, and a parody filesystem.
[ contents ]
1. Why a Terminal?
Every portfolio site has a dark mode toggle. Some have a blog. A few have a "uses" page. I wanted something that makes people type ls and hit enter before they realise it's not their shell.
A fake terminal is the honest version of a portfolio. It doesn't pretend to be a static document. It's a REPL. You poke it, it responds. The interaction is the content.
Also: I'd never built a terminal emulator in React. The best way to learn something is to ship it publicly where it can break.
2. The Architecture: 57KB of React
TerminalShell.tsx is the monolith. 57,380 bytes. One file. No sub-components. Here's why:
State is the terminal. The entire session lives in one useReducer:
type TerminalState = {
history: Line[]; // every line ever printed
cwd: string; // virtual filesystem path
env: Record<string, string>;
theme: ThemeName;
tabs: Tab[]; // multiple shell sessions
commandHistory: string[]; // up-arrow recall
historyIndex: number;
// ... 15 more fields
};
Every keystroke dispatches. Every command returns a Line[] to append. The reducer is 400 lines of pure functions — testable, deterministic, no side effects.
Commands are data, not code. Each command registers in src/commands/index.ts:
export const COMMANDS: Record<string, Command> = {
ls: {
fn: cmd_ls,
usage: "ls [path]",
desc: "List directory contents",
man: MAN_ls
},
// 30+ more
};
The parser splits on whitespace, handles quotes, expands ~, resolves relative paths against cwd. No external dependency. 80 lines.
Virtual filesystem is a nested object literal in src/filesystem/index.ts. 2,000+ lines of fake files: /home/guest/notes/todo.txt, /etc/passwd (with fake users), /usr/games/, /proc/version, /dev/null. cat, head, tail, grep, wc all work against it. vim opens a modal editor component. rm warns but doesn't persist (it's a demo).
3. Games: Wordle, Minesweeper, TicTacToe, 2048
Each game is a self-contained state machine in src/games/. They run inside the terminal — no canvas, no WebGL, pure ANSI/Unicode.
Wordle (wordle.ts): 120 lines. Renders the grid as coloured boxes using \x1b[48;2;r;g;bm true-color backgrounds. Keyboard handling via readline-style input capture.
Minesweeper (minesweeper.ts): 200 lines. Recursive flood-fill reveal. Flag mode with f prefix. Renders as emoji grid (🟦 🚩 💣).
TicTacToe (tictactoe.ts): 80 lines. Minimax AI. Unbeatable. Runs in the terminal buffer.
2048 (2048.ts): 150 lines. Arrow key handling via escape sequences. Tile merging animation via frame-by-frame re-render.
All games share a Game interface:
interface Game {
init(): GameState;
step(state: GameState, input: string): GameState;
render(state: GameState): string; // ANSI output
isDone(state: GameState): boolean;
}
The terminal shell spawns a game loop that captures all input until q or Ctrl+C.
4. Themes: Retro, Matrix, Amber, Green, Solarized
Themes live in src/themes/index.ts. Each is a colour palette + CSS custom properties:
export const themes: Record<ThemeName, Theme> = {
retro: {
name: "Retro",
background: "#1a1a2e",
foreground: "#e8e8e8",
cursor: "#ff6b6b",
selection: "#ff6b6b40",
ansi: [/* 16 colours */],
},
matrix: { /* ... */ },
// 8 more
};
Switching themes (theme <name>) dispatches a SET_THEME action that updates CSS variables on :root. The entire UI — including the rest of the site — re-colours instantly. No reload.
5. Man Pages: The Parody Layer
man <command> renders roff-style output. Written by hand. man ls shows flags that don't exist. man vim has a "BUGS" section: "Exits only via :q! or power outage." man sudo says "This is not your machine. You have no power here."
The man pager (less-style) supports j/k, g/G, /search, n/N. 300 lines of scrolling logic.
6. What I'd Do Differently
Split the reducer. 400 lines in one function is fine until you add the 15th command. Next time: combineReducers per domain (fs, process, ui, games).
Extract a TerminalEngine class. The shell logic is coupled to React. A headless core would let me run the same terminal in a Web Worker, or on the server for SSR.
Persist state to IndexedDB. Refresh loses everything. localStorage is too small for full history. IndexedDB handles 50MB+.
Add a real PTY backend. WebSocket + xterm.js + actual shell would make it actually useful, not just a toy. But then it's not an easter egg — it's a product.
Write tests. Zero tests. The reducer is pure — it begs for property-based testing. fast-check could generate random command sequences and assert invariants (cwd always valid, history never shrinks, tabs isolated).
7. The Hidden Feature
Type matrix in the terminal. Wait.
The theme doesn't just change colours. It spawns a background rain animation using requestAnimationFrame that writes directly to the terminal buffer — falling katakana, green trails, the works. It's 40 lines of code that makes people smile.
That's the point. A portfolio should have at least one thing that serves no purpose except delight.
Part 2: The command parser, ANSI escape handling, and building a virtual filesystem that feels real.