Skip to main content
TASIOMIND.DEV — OPERATIONAL▸▸▸FULL STACK DEVELOPER @ GWQ SERVICEPLUS AG▸▸▸FOUNDER — K8SGPT.AI▸▸▸OPEN SOURCE: ACTIVE▸▸▸DISTRIBUTED SYSTEMS / KUBERNETES / AI▸▸▸RUST + GO + PYTHON▸▸▸FIELD TESTED / STATUS — NOMINAL▸▸▸LOCATION: EUROPE/BERLIN▸▸▸TASIOMIND.DEV — OPERATIONAL▸▸▸FULL STACK DEVELOPER @ GWQ SERVICEPLUS AG▸▸▸FOUNDER — K8SGPT.AI▸▸▸OPEN SOURCE: ACTIVE▸▸▸DISTRIBUTED SYSTEMS / KUBERNETES / AI▸▸▸RUST + GO + PYTHON▸▸▸FIELD TESTED / STATUS — NOMINAL▸▸▸LOCATION: EUROPE/BERLIN▸▸▸
Engineering

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.

Aland Baban · August 2026 · 4 min read
[ 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:

code
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:

code
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:

code
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:

code
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.