Letter Bounced: a fast solver for Letter Boxed
Letter Bounced is a solver for the New York Times word puzzle, "Letter Boxed". Try it out! The source is on GitHub.
This page collects the README and the technical notes from the repository into one place: why I wrote it, the rules of the game, the command-line tool, the web application, the algorithm, and how the website works.
Why?
The New York Times puzzle page is very popular! Yet, the existing solvers that I know of suck.
- Hard to use
- Slow
- Only suggest "best" answers with ridiculously rare words
- Give redundant answers
- Grind to a halt when the solution is more than two words
- Weren't written in Rust
I got obsessed with this game about a year ago and I kept thinking of ways to write a fast solver. Here's my attempt, and not uncoincidentally it's my first real Rust project.
What is Letter Boxed?
Letter Boxed puzzles are a set of letters in a box shape. Players must connect all the letters in the puzzle with a chain of valid words, as in the following screenshot.
Game rules
- Four-sided puzzle: Letters are arranged on four sides of a square. (Though, Letter Bounced may allow other shapes)
- No same-side connections: You cannot connect two letters from the same side. Think of it as bouncing between sides.
- Word chaining: Each new word must start with the last letter of the previous word
- Complete coverage: All letters must be used across your word sequence
There is no score in the New York Times' version of Letter Boxed, but some puzzles are harder than others. Sometimes there are hundreds of solutions, and sometimes there is only one. Skilled players try to complete the puzzle in fewer words.
Example
Given the puzzle:
JGH NVY EID ORP
The only two-word solution is: DOJO-OVERHYPING
A possible three-word solution is: DOVE-ENJOYING-GRYPHON
Note how each letter hops to a different side, and the words are connected by their first/last letters.
The algorithm
Goals
I wanted the fastest Letter Boxed Solver, just for fun, and also maybe as a basis for a game that could be based on the same format.
Spoilers: it's really fast
Frustrations
There are many other Letter Boxed solvers on the internet, but I found them lacking. For example, given this board:
VYQ FIG OTE XLU
All solvers will immediately find FOXGLOVE-EQUITY. Even the slowest JavaScript solver
finds it right away.
Some of them will try to find more solutions, and then add things like
ELF-FOXGLOVE-EQUITY... which is obviously redundant.
The instructions for playing Letter Boxed often say to find solutions that are less than five or six
words long. But none of these solvers could find a good plausibly human-found solution that was even
four words long, such as FOG-GLOVE-EXILE-EQUITY.
Example board: VYQ,FIG,OTE,XLU
Running on this board produces solutions like:
- 2-word:
foxglove-equity(best) - 4-word:
fog-glove-exile-equity - 3-word:
foxglove-equivoque-egoity(shorter but rare words ranked lower) - 4-word:
quit-tye-elf-foxglove
Insights
Digraphs
Letter Boxed is not a game of making words with letters, but making words from digraphs.
The above board:
VYQ FIG OTE XLU
Is really a specification that we're allowed to make words with letter pairs like VI,
FE, or even QX.
Once we filter the dictionary down to playable words, for that puzzle, we're left with only 306 words! That sounds like we can use almost any algorithm and get solutions really fast.
...Or does it?
Coverage
The solution must include every letter on the board. This sounds like an Exact Cover problem.
I originally messed around for far too long with Knuth's Algorithm X, which should probably be the title of a 21st-century cyberpunk thriller.
Those Wikipedia links will give you all the mathy details, but in human terms, think of it like this. You're trying to buy some gifts for someone, and they've told you they want nine particular bath items from some fancy bath product store. The bath product store sells them in convenient baskets of two to four items, with hundreds of possible combinations, but not every combination. How can you get your friend exactly what they want and nothing more?
Unfortunately Exact Cover algorithms are not what we want here. It's very common for a Letter Boxed solution to have to repeat certain letters. Algorithm X is only fast because it's great at eliminating solution paths that already have stuff we want. I tried to make an "inexact cover" algorithm that leveraged Knuth's ideas, but it quickly degraded to exponential search time.
Unfortunately, as far as I can tell, Letter Boxed solution finders must run in exponential O(nd) time.
- n is the number of words to consider
- d is the length of the solutions you want
So there's nothing we can do other than make it very efficient — and reduce n as much as possible!
We've already done a good job of cutting down the total dictionary. However, to find four word solutions even with 306 words is challenging. Naively we would have to look at over 8 billion combinations.
Chains
It's at the moment a simple recursive search. It's depth-first, because my first attempts at doing breadth-first search just blew up all of memory. (I may revisit this). But the speed and simplicity of depth-first are fine for now, even if we end up doing some redundant work. We plunge into the "depths" multiple times to find longer and longer solutions.
As long as a word adds something useful to the path, we recurse downwards.
However, each time we extend the length of the solution, we also cut down the obscurity of the vocabulary that we will consider. (n.b. unimplemented because it's already fast enough)
We cut it down even more significantly by building an index of first letter to word.
Bitmasks
Letter Boxed boards are only 12 letters. This means we can also cache a representation of what letters a particular word covers with a bitmask.
We construct a table of letters for every board we are solving, and then also map all words in our dictionary to what bits they cover.
Then it's easy to know if a chain of words is a full solution; we bitwise-or all their bitmasks together.
Redundant path elimination
For the above puzzle, FOXGLOVE-EQUITY is a great solution, but
FOXGLOVE-EYE-EQUITY looks stupid. How to eliminate it?
We're building up the path as we recursively traverse. There's no way to know in advance whether the path we're on will turn out to be redundant.
We leverage the bitmasks again. Every time we find a solution, we go back and consider if any subsequences of that solution could be skipped with no loss of letter coverage.
Algorithm stages
Claude wrote most of what's below here. It might even be correct.
0. Filter every word ever to words playable in Letter Boxed, common words first
This is a one-time preprocessing step, not part of the solver runtime.
Input sources:
- Collins Scrabble Words 2019 (newline-delimited, ~279,000 words)
- Google NGrams word frequencies (tab-separated: word + count)
Process (dictionary_builder.rs):
- Simultaneous iteration: Both files are pre-sorted alphabetically, so we iterate through them in lockstep
- Matching words: When words match, check if playable
- Playability filter:
- Minimum 3 letters
- No adjacent repeated letters (e.g., "BOOK" → rejected, "DOJO" → accepted)
- Frequency scoring:
- Convert Google NGrams count (up to 236) to log2 scale
- Cap at 31 to fit in 5 bits (saves space)
- Example: "the" (14 billion) → 31, "foxglove" (115k) → 16
- Output format:
word frequency_score(e.g.,foxglove 16) - Post-processing: Sort by frequency descending, then alphabetically
Result: Pre-filtered, pre-sorted dictionary of ~180,000 playable words with frequency scores
Why this matters:
- Eliminates impossible words before runtime (words with doubled letters)
- Frequency data enables human-pleasing solution ranking
- Sorting allows early termination when generating solutions
1. Filter the dictionary to words playable on the board
Input: Full dictionary with frequency-ranked words
Process:
- Extract all digraphs from dictionary words
- Compute valid digraphs on the board (cross-side pairs only)
- Find intersection: board digraphs ∩ dictionary digraphs
- Filter words where ALL digraphs are in the usable set
Example: Board VYQ,FIG,OTE,XLU produces digraphs like "fo", "ox", "gl", "ve" (cross-side) but NOT "vy", "fg", "ot" (same-side)
Result: Playable dictionary (much smaller than original)
2. Dictionary sorting
Implicit in data: Words have frequency scores (i8, 0-255)
- Higher frequency = more common/desirable word
- Frequencies come from pre-processed dictionary file
- Used later for solution scoring
3. Solver initialization
Precomputes word bitmaps:
| Side | 0 | 1 | 2 | 3 | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Letter | v | y | q | f | i | g | o | t | e | x | l | u |
| Bit | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| foxglove | x | x | x | x | x | x | x | |||||
| equity | x | x | x | x | x | x | ||||||
| bitwise-OR | x | x | x | x | x | x | x | x | x | x | x | x |
Indexes words by first letter:
- 'f' → [foxglove, fog, flog, futile, ...]
- 'e' → [equity, exile, evolve, ...]
- Enables O(1) lookup for word chaining
All-letters mask: 0b111111111111 (12 bits set for 12 letters)
4. Recursive search with bitmasks
Strategy: Depth-first search for exact-length solutions (1-word, 2-word, 3-word, 4-word)
Key optimizations:
a) Target length enforcement
for target_words in 1..=4 {
search_recursive(&mut current_path, 0, None, &mut solutions, target_words);
}Searches for 2-word solutions first (most desirable), then 3-word, etc.
b) Bitmap-based coverage tracking
let new_bitmap = covered_bitmap | word_bitmap.bitmap;
// Only continue if this word adds new letters
if new_bitmap != covered_bitmap { ... }Fast bitwise OR operation to track visited letters
c) Word chaining constraint
if let Some(ch) = last_char {
// Must start with last character of previous word
self.words_by_first_letter.get(&ch)
}Uses pre-built index for instant lookup
d) Completion detection
if covered_bitmap == self.all_letters_mask && current_path.len() == target_wordsSingle bitwise comparison checks all 12 letters visited
5. Redundancy detection with bitmasks
Problem: foxglove-eye-equity is redundant because
foxglove-equity covers all letters
Solution: Test all redactable subsequences
Redactable subsequences:
- Any subsequence removing the head word (e.g.,
[eye, equity]from[foxglove, eye, equity]) - Any subsequence that maintains valid chains (e.g.,
[foxglove, equity]— last char of foxglove = 'e' matches first of equity)
Redundancy check:
for indices in redaction_indices {
let mut combined_bitmap = 0u32;
for &idx in &indices {
combined_bitmap |= wb.bitmap; // Bitwise OR
}
if combined_bitmap == self.all_letters_mask {
return true; // Shorter solution exists!
}
}Example:
foxglove-eye-equity: Check[foxglove, equity]→ bitmap = all letters → redundantflog-glove-exile-equity: Check[flog, equity]→ missing letters → not redundant
6. Solution scoring
Formula:
let min_frequency = words.iter().fold(256, |acc, w| min(acc, w.frequency));
let score = (min_frequency * 10) / words.len();Favors:
- Common words (high frequency)
- Fewer words (lower denominator)
Example:
foxglove-equity: min_freq=16, words=2 → score=80fog-glove-exile-equity: min_freq=16, words=4 → score=40
Sorting:
solutions.sort_by(|a, b| b.score.cmp(&a.score));Descending order: best solutions first
7. Early termination
Max solutions limit:
if solutions.len() >= self.max_solutions {
break; // or return
}Stops searching once enough solutions found (default: 500)
Performance characteristics
Bitwise operations for:
- Letter coverage: O(1) per word
- Completeness check: O(1)
- Redundancy detection: O(subsequences × words)
Indexing for:
- Next word lookup: O(1) via HashMap
- Word chaining: No linear search needed
Search pruning:
- Only adds words with new letters
- Exact-length targeting prevents exploring invalid paths
- Early termination on solution count
Output format
Solutions sorted by score (common words, fewer words = better):
foxglove-equity # score: 160 (best) fog-glove-exile-equity # score: 40 quit-tye-elf-foxglove # score: 26
Human-pleasing results appear first due to frequency-based scoring combined with word count penalty.
Algorithmic complexity analysis: IT'S LIKE REALLY FAST!!!
Again this is a straight dump from Claude's brain. It's actually underestimating how fast it is typically, even the WASM version is returning thousands of results at great depths, in under a second.
Theoretical worst case: O(nd)
The recursive search has exponential time complexity where:
- n = number of playable words on the board
- d = maximum solution depth (typically 4)
In the worst case without pruning, we'd explore nd combinations.
Typical board example: VYQ,FIG,OTE,XLU
For this board:
- Dictionary size: 180,731 total words
- After digraph filtering: 254 playable words (99.86% reduction!)
- Search depth: 1 to 4 words
- Maximum theoretical combinations: 2544 = 4.15 billion paths
However, actual performance is dramatically better due to aggressive pruning.
Pruning mechanisms that reduce complexity
- First-letter indexing
- After first word, only ~21 words average per starting letter (254/12)
- Reduces branching factor from 254 to ~21 at depth 2+
- Actual complexity closer to: n × (n/12)(d-1)
- Bitmap coverage check
- Rejects paths that don't add new letters
- Prunes ~60-80% of branches as puzzle fills up
- Most effective at depths 3-4
- Target length enforcement
- Searches exact depths independently
- Stops at depth d, doesn't explore d+1 unnecessarily
- Early termination once max_solutions found
- Redundancy detection
- Eliminates solutions with redundant words
- Runs post-search, O(s × 2w) where s=solutions, w=words per solution
- Typically negligible compared to search time
Practical performance
For the VYQ,FIG,OTE,XLU board:
- Finds 500 solutions in ~1 second (unoptimized debug build)
- Effective paths explored: ~10,000-100,000 (estimated from runtime)
- Pruning reduces actual work by 99.999% vs theoretical maximum
Scaling characteristics
- Best case: O(n) — immediate 2-word solution found
- Typical case: O(n × (n/12)2) ≈ O(n2 / 144) for 3-word solutions
- Worst case: O(nd) for puzzles requiring 4+ word solutions
The digraph filtering step is the most critical optimization, typically reducing the search space by 99%+ before the recursive search even begins.
How the website works
The site is at https://neilk.github.io/letterbounced/. It's a static bundle on GitHub Pages — no server, no API, and at this time, not even any analytics.
Using it
Note there isn't a "Solve" button. The solver runs whenever the puzzle changes. You can type letters yourself, load some presets, or load today's New York Times puzzle. (That's scraped by a periodic job on GitHub Actions. See the web application, above.)
You get the first 10,000 solutions, bucketed by word count, and sorted by an arbitrary score that I made up which tries to sort more ordinary words first.
Your board is stashed in localStorage under letterBoxedPuzzle, so a reload
picks up where you left off.
There is a bouncing animation whenever a letter changes. I have some ambitions to turn this into a game with more, well, bounciness. For now it's just a flourish.
How solving works
The solver is pure, plain Rust in src/{board,dictionary,solver}.rs, and doesn't do any
I/O.
The CLI solver and the web-embeddable WASM version wrap the solver.
npm run build emits a dist/ that looks roughly like this:
dist/assets/index-Bf7Ne_iy.js 45 KB Svelte app, main thread dist/assets/solver-worker-C1Kn0FFQ.js 7 KB worker entry dist/assets/letter_bounced_bg-DNWSUsk8.wasm 87 KB the actual solver dist/dictionary.txt 2.2 MB fetched at runtime, not bundled
We use a "solver worker" — a Web Worker — to do the actual solving in WASM. That stays off the main UI thread, to keep everything snappy.
The WASM shell exports three functions — initialize_dictionary,
solve_game, cancel_current_solve. solve_game returns a
JavaScript-compatible Promise via future_to_promise.
The dictionary stays a separate 2.2 MB text file (700 KB over the wire, gzipped). Rust parses it into
180,731 words in less than a second, and parks it into a OnceLock<Arc<Dictionary>>
for the life of the page.
Why there's no Solve button
Because it's fast. These results are on my ancient 2019-era Macbook.
| Board | Time | Result |
|---|---|---|
VYQ FIG OTE XLU |
276ms | 1,524 solutions — exhaustive |
PRC YAN LKH SIO |
749ms | hit the 10,000 cap |
JGH NVY EID ORP |
2,357ms | hit the 10,000 cap |
I find it usually finishes before I've got my fingers out of the way.
Three things keep that from turning into a mess when you type quickly:
- A 300ms throttle on the main thread, so a burst of keystrokes collapses into one or two solves rather than twelve.
- A monotonic
solveId. EverysolvePuzzle()bumps a counter and stamps the outgoing message. When a result comes back, the store compares stamps and drops anything stale. - Short circuit: an incomplete or impossible board doesn't do anything.
The UI
CSS, hand-written, no framework
CSS is really good these days. For a simple site like this, you don't need frameworks or build steps.
Just Svelte's scoped <style> blocks plus a global app.css for the
theme variables.
I found some nice CSS tricks for the box layout — it's a 5x5 CSS grid. Each input is placed by
HTML ID. aspect-ratio: 1 keeps it square and
font-size: clamp(24px, 8vw, 80px) scales the letters. The jump animation is four
keyframe sets — jump-up, jump-right, jump-down, and
jump-left.
Dark mode, by lightness inversion
Every colour in the app is a CSS custom property — --color-text,
--color-bg-container, and 21 others — defined on :root and
overridden in a @media (prefers-color-scheme: dark) block. Nothing hardcodes a hex
value in a component.
Dark values are generated by
endarken-color.js,
which is about as dumb as a colour tool can be while still working: convert to
hue-saturation-lightness, and invert the lightness.
License
Copyright Neil Kandalgaonkar, 2025-2026. This software is NOT freely redistributable.