Back

Zebradoodle

Web / Games | 2025

Zebradoodle
https://mustakim.dev/projects/zebradoodle
+
Wordle · daily · streak 12
Zebradoodle
quordle · sedecordle · nerdle
C
R
A
N
E
S
L
A
T
E
R
A
F
T
S
Q
W
E
R
T
Y
U
I
O
P
A
S
D
F
G
H
J
K
L
Z
X
C
V
B
N
M
Overview

A 2022 Java CLI prototype, reborn in the browser.

My Role
Sole engineer

A weekend project rebuilt from a Java command-line Wordle clone I wrote between September 2022 and January 2023 in undergrad. The original lived in legacy/Zebradoodle.java and loaded 26 per-letter dictionary files off disk. This rebuild brings the same engine into the browser as React + React Router and adds three siblings — Quordle, Sedecordle, and Nerdle — that share the original scoring core.

Stack
React 17 · React Router 5 · Create React App (react-scripts 5) · plain CSS · canvas-confetti · localStorage

No backend, no design system, no Tailwind. The whole thing builds as a static bundle and deploys to GitHub Pages. State (stats, streaks, daily-puzzle resume) lives in localStorage so refreshes never lose progress and the daily puzzle picks up where it left off.

Timeline
2022 (Java CLI) → 2026 React rewrite

Live at mustakimfs.github.io/zebradoodle. The repo's commit history reflects the rebuild dates; the algorithmic lineage goes back to the original 2022 prototype.

Highlights

One scoring core, four very different boards.

The interesting part isn't any single mode — it's how the same two-pass scoring engine drives all four. Wordle is one board. Quordle stacks four. Sedecordle stacks sixteen. Nerdle replaces the word with an 8-character arithmetic equation and the dictionary with a tokenizer + evaluator that enforces operator precedence and exact integer division before any guess can even be scored.

4
Game modes
Wordle · Quordle · Sedecordle · Nerdle
14.8K
Word bank size
curated 5-letter list
0
Backend services
static bundle · GitHub Pages
Two-pass scoring — ported from the Java compare().
First pass marks position-correct letters as correct. Second pass walks the remaining guess letters; each one matches an answer letter at most once, with correct-position matches consumed first. Handles repeated letters the way Wordle does. Lives in src/lib/scoring.js.
Deterministic daily seeding via FNV-32 of the date.
Every player gets the same puzzle on the same calendar day. The seed is a stable hash of YYYY-MM-DD — no server, no sync, no “today's word” endpoint. Practice mode rolls a fresh random puzzle on demand.
Nerdle ships its own tokenizer + evaluator.
Equations are exactly 8 characters, exactly one =, standard operator precedence (* / before + -), exact integer division (no fractions), no leading zeros. The bank in src/data/nerdleAnswers.js is re-validated on module load so a corrupt answer can never reach a player.
Context

Wordle is the most cloneable design of the decade.

The interesting question wasn't “can I clone Wordle” — anyone can. It was “can the same engine drive a 1-board, a 4-board, a 16-board, and a math-equation variant without forking the scoring logic four times.” The README's table shows the answer: yes, with one shared scoring function and per-mode dictionaries.

Wordle / NYT · public design
A 5-letter guess, 6 attempts, color-coded feedback. Designed for daily ritual play with one shared puzzle across the player base.
The pattern the four variants extend
r/wordle · repeated-letter complaint threads
My guess had two E's but only one got marked yellow! Is that a bug?
What the two-pass algorithm exists to solve — and what gets it wrong if you implement naively
Nerdle · richardmann.com, 2022
Replace the word with an arithmetic equation. Same UI, totally different validation logic.
The math-mode template this implementation follows
Mustakim · Sep 2022 — Jan 2023
legacy/Zebradoodle.java: a CLI Wordle that read 26 per-letter dictionary files off disk and scored guesses with a two-pass compare() method.
The undergrad prototype this 2026 build descends from
1.0The lineage.DIAGRAM
The Problem

Four modes, one core, zero servers.

1
One scoring function for all four modes
Wordle, Quordle, and Sedecordle all use the same letter-comparison logic — Quordle just runs it against four answers per guess and Sedecordle against sixteen. Forking the scorer would mean four versions of the same repeated-letter bug to maintain.
2
Repeated-letter correctness, port-faithful
The original Java two-pass compare() handles cases like guess BOOKS vs answer BLOOD correctly (only one O marked, repeated-O isn't double-yellow). JS port has to produce byte-identical state on every supported input.
3
Deterministic daily seeding without a server
Every player has to get the same puzzle on the same day. A server would be the obvious answer; the constraint is no server. Solution: FNV-32 hash of the date string is the seed.
4
Nerdle's validation is its own grammar
An equation guess can't even be scored until it's been tokenized, evaluated, and confirmed to satisfy 8-char length / single equals / precedence / integer division / no-leading-zero rules. Failing any of those is a reject before the scorer ever sees the input.
5
State that survives a reload
A player who refreshes mid-Wordle expects to land back on the same board with the same guesses. localStorage holds the in-progress game per mode, the daily puzzle ID, and the running stats.
6
Static bundle, no backend
The whole thing has to deploy as static files on GitHub Pages — no API, no auth, no analytics. Everything that needs to persist persists locally on the player's device.
North-star principles
The scorer is the contract.
Every mode dispatches to the same scoring function. New modes mean new dictionaries and new board layouts — never new scoring.
Determinism over server.
Daily puzzles are picked by a stable hash, not fetched. Same date everywhere on Earth → same puzzle. No coordination required.
Validate before you score.
In Nerdle, an invalid equation isn't a wrong answer — it's a malformed input. The tokenizer + evaluator reject pre-score so the player gets a clear 'invalid equation' toast, not a misleading color row.
Process

Port the core, multiply the boards, then change the alphabet.

V1

Wordle in React — port the Java scorer.

Started by porting Zebradoodle.java's two-passcompare() to JS as src/lib/scoring.js. Built the Board / Tile / Keyboard components against it, hooked up localStorage for stats, and shipped a 1-board Wordle that behaves byte-identically to the original CLI on the same dictionary.

V2

Multiply the board — Quordle and Sedecordle.

Two new modes for the price of zero new scoring code. Quordle renders four parallel boards and dispatches each guess to the same scorer against four answers. Sedecordle does the same at 16x with a 21-guess budget instead of 9. The scoring contract didn't change — the layout and the budget did.

V3

Nerdle — change the alphabet entirely.

The math mode is the interesting one. Equations are 8 characters over 0-9 + - * / =. Built a small tokenizer that splits an equation into tokens, an evaluator that respects precedence and rejects fractional division, and a validator that enforces the 8-char / single-equals / no-leading-zero rules. Only after validation does the scorer get to run. The answer bank is re-validated on module load so a corrupt entry can never reach a player.

The double-letter regression I almost shipped

First JS port of the scorer was a 20-line single-pass that walked the guess once. It worked on 95% of inputs and silently broke on the rest — most visibly on guesses like EERIE against EATER where the second E got marked yellow when it shouldn't have. Rebuilt against the original Java two-pass with a small set of regression tests (BOOKS / EERIE / SASSY / BLOBS) and the bug disappeared. The lesson generalizes: when in doubt, port the original algorithm, then then simplify if you can prove it's equivalent.

Scoring function shape
Before — V1 single-pass
One walk over the guess. Correct on most inputs, silently wrong on repeated letters (EERIE / BOOKS / SASSY).
After — V3 two-pass, Java-faithful
First pass: position-correct → correct. Second pass: remaining guess letters can match remaining answer letters at most once each. Repeated-letter cases produce the same colors the original Java CLI emitted, on every regression test.
3.0DIAGRAM
Daily-puzzle picking
Before
(Considered) fetch today's puzzle ID from a server. Adds an API surface, a deploy concern, and a single point of failure.
After
FNV-32 hash of YYYY-MM-DD modulo the word-bank size. Pure function, no network, identical result everywhere on Earth on the same calendar day.
3.1DIAGRAM
Architecture

One scoring contract, four dictionaries, sixteen boards.

The shape is small. Each game mode is a page under src/pages/ that owns a board count, a guess budget, and a dictionary. All of them call the same scoring + state-machine primitives in src/lib/. The two interesting paths diverge on input: word modes go straight to the scorer; Nerdle validates the equation first.

zebradoodle: ~/guess-lifecycle
player@browser:/$submit guess → 'CRANE' (Wordle)
[1] dict.has(guess) ? continue : toast('not a word')
[2] scoring.compare(guess, answer) · two-pass · → [correct,present,absent...]
[3] state.apply(guess, colors) · update board · check win/loss
[4] localStorage.set · stats · streak · in-progress game
─── for Nerdle, [1] is replaced by ────────────────────
[1a] tokenize · split into digits / ops / equals
[1b] validate · 8 chars · one = · no leading zeros
[1c] evaluate · lhs op rhs · precedence · integer-only
[1d] lhs === rhs ? continue : toast('invalid equation')
6.0Guess lifecycle (word + Nerdle).DIAGRAM
Two-pass scoring (JS port of compare())
function compare(guess, answer) {
  const colors  = Array(guess.length).fill('absent')
  const remaining = answer.split('')

  // Pass 1 — mark correct, consume from remaining
  for (let i = 0; i < guess.length; i++) {
    if (guess[i] === answer[i]) {
      colors[i] = 'correct'
      remaining[i] = null
    }
  }

  // Pass 2 — for each non-correct letter,
  // try to match against a still-remaining answer letter
  for (let i = 0; i < guess.length; i++) {
    if (colors[i] === 'correct') continue
    const j = remaining.indexOf(guess[i])
    if (j !== -1) {
      colors[i] = 'present'
      remaining[j] = null  // consume — repeated letter safety
    }
  }

  return colors
}
Nerdle validator + evaluator
// Grammar
//   equation := expr '=' expr        (8 chars total)
//   expr     := term (('+'|'-') term)*
//   term     := factor (('*'|'/') factor)*
//   factor   := integer
//
// Constraints
//   * exactly 8 chars
//   * exactly one '='
//   * no leading zero (except literal 0)
//   * '/' must yield an integer
//   * standard precedence (* / before + -)

function isValidNerdle(eq) {
  if (eq.length !== 8) return false
  const [lhs, rhs, ...rest] = eq.split('=')
  if (rest.length !== 0 || lhs == null || rhs == null) return false
  if (!noLeadingZeros(lhs) || !noLeadingZeros(rhs)) return false
  const l = evalExpr(lhs); const r = evalExpr(rhs)
  return Number.isInteger(l) && Number.isInteger(r) && l === r
}
6.1Scorer + Nerdle grammar.DIAGRAM
Final Designs

Four boards under one front door.

The product is four pages and one home screen. Live at mustakimfs.github.io/zebradoodle — no auth, no install, no permissions. Every mode has a Daily and a Practice button; stats persist per mode in localStorage.

Zebradoodle home — Pick a puzzle, four mode cards
7.0Home — mode select (Wordle · Quordle · Sedecordle · Nerdle).IMAGE
Nerdle board mid-game — Daily #1370, equation 9+8-7=10
7.1Nerdle — Daily #1370, 8-tile equation board + numpad keyboard.IMAGE
mustakimfs.github.io
https://mustakimfs.github.io/zebradoodle
+
Live app — Zebradoodle puzzle collection
Play now ↗
7.2Live at mustakimfs.github.io/zebradoodle — click to play.IMAGE
Retrospective

Small project, real lessons.

Worked

Porting the Java compare() first.
The two-pass scoring is the contract every other mode depends on. Getting that byte-correct against the original before adding Quordle / Sedecordle / Nerdle meant every later mode inherited the correctness.
No server, by design.
FNV-32 of the date string handles 'today's puzzle for everyone' without any backend. localStorage handles per-player state. The whole product ships as a static bundle on GitHub Pages.
Validate-before-score for Nerdle.
Distinguishing 'invalid equation' from 'wrong answer' makes Nerdle play correctly. The validator rejects malformed input pre-score so the player gets a clear toast instead of a misleading color row.

Didn't

React 17 + React Router 5 + CRA is dated.
Create React App is unmaintained as of 2025 and React 17 / React Router 5 are two majors behind. Build still works, but the dev experience would improve materially on Vite + React 18 + React Router 6.
No keyboard accessibility audit.
The Wordle keyboard is mouse / touch first. Hardware-keyboard players work fine; assistive-tech support hasn't been formally tested. ARIA + focus management would help.
Stats are device-local.
Streak resets when a player switches browsers or clears storage. A 'sync via paste-a-code' export feature would solve it without needing a backend.

Next

Migrate to Vite + React 18.
Same code, faster dev loop, modern toolchain. The renderer is already framework-light enough that the migration is mostly tooling, not code rewrites.
Hard mode + colorblind palette.
Hard mode (must reuse correct letters in subsequent guesses) is in the original Wordle spec but not implemented here yet. Colorblind palette is half an evening of work and a real UX win.
Stats export / import code.
Encode the local stats blob to a copy-paste string. Paste it on another device to merge. No accounts, no server, no telemetry — just a string the player owns.