Aaron Ang

Letter Boxed: From the Cloud Back to the Browser

Background

Letter Boxed is one of the New York Times’ daily word games. Twelve letters sit around the sides of a square, three per side. You make words by drawing a path between letters, with a few rules:

A solution traces a path across the square, one letter at a time:

MORPHS is drawn letter by letter, alternating sides of the square. SINGLED starts from its final S and covers the remaining six letters, solving the puzzle in two words.
Solid orange lines trace MORPHS; dashed blue lines trace SINGLED.

I first wrote a solver for this in 2022, as a Java assignment for CS 112. Since then, the same algorithm has lived in a browser tab, in a Node.js cloud function, in a Go cloud function, and finally back in the browser, now with a GPU helping out. You can try the current version in your browser.

This post follows that path. Each move traded one problem for another. The last move was possible because of two changes that don’t depend on where the code runs: representing letters as bits, and doing work before the search starts rather than during it.

The First Solver: Backtracking

The core algorithm hasn’t changed much since the assignment. It’s recursive backtracking: build a solution one letter at a time, and whenever the partial answer breaks a rule, undo the last letter and try the next option.

At each step, the solver considers adding each of the 12 letters to the current word, and immediately rejects the letter if:

It can also end the current word, if it’s a real word of three or more letters, and start the next word with the same letter.

The “start of any dictionary word” test does most of the pruning. The dictionary is loaded into a map where every prefix of every word is a key: “M”, “MO”, “MOR” and so on map to “prefix only”, while complete words like “MORPH” map to “full word”. If “MRX” isn’t in the map, no word starts that way, and the solver never explores anything beneath it.

On top of this, the solver uses iterative deepening. It first looks for a one-word solution, then two words, then three, up to five. So the first solution it finds is also one of the shortest in words. The app calls this Solve.

After a solve, the button turns into Find Best. Among solutions with the fewest words, it finds the one with the fewest total letters. Solve might return MORPHS SHIELDING, while Find Best returns MORPHS SINGLED. Find Best is much more expensive: instead of stopping at the first solution, it has to rule out every other one.

Where the Solver Runs

The algorithm stayed the same, but where it ran changed four times. The animation below follows the solver from the browser’s main thread to the cloud and back to a Web Worker:

Three setups: in 2022 the solver blocks the browser’s main thread; in 2023 a cloud function solves it over the network; in 2026 a Web Worker solves it locally, with WebGPU for Find Best.
The tabs replay each year’s setup in turn; the final frame shows 2026.

Where the solver has run
WhenWhere it runsWhat it fixedWhat it cost
Spring 2022Java, command line—No interface
2022TypeScript on the browser’s main threadA visual, shareable appHard puzzles froze the page
May 2023Node.js on Google Cloud FunctionsNo more freezingNetwork latency, a server to maintain
June 2023Go on Google Cloud FunctionsLower latency, gzipped responsesStill a network round trip
April 2026TypeScript in a Web Worker, plus WebGPUNo network, no freezingDepends on the user’s device
Dates from 2022 onward are from the repository’s commit history; the Java version predates it.

On the main thread

The first web version ran the solver directly in the page’s JavaScript, alongside every button click and repaint. JavaScript in a browser tab runs on one main thread, so while the solver was searching, nothing else could happen: the page couldn’t repaint, and buttons stopped responding until the search finished.

In the cloud

The fix in 2023 was to move the solver to a Google Cloud Function. The browser sends the puzzle’s letters, the server solves it, and the answer comes back. My README at the time put the tradeoff this way:

Significant reduction in client RAM and CPU load in exchange for slightly increased response time.

This fixed the freezing completely, since the browser was only waiting on a network request. A Web Worker, covered below, would have fixed it without a server, but I didn’t know about them at the time. With the server, every solve paid for a network round trip, plus a cold start whenever the function hadn’t run recently and the cloud provider had to start a fresh instance. A month later I rewrote the function in Go, which typically starts up and runs CPU-heavy code faster than Node.js, and I gzipped the responses to cut transfer time.1

Back in the browser

By 2026, running the solver locally looked attractive again, for three reasons:

  1. Web Workers. A Web Worker runs JavaScript on a separate thread. The page sends it a message, and the worker runs the solver and posts a message back. The main thread never blocks, which fixes the original freezing without a server.
  2. WebGPU. Browsers can now run general-purpose programs on the GPU through WebGPU.2
  3. A faster solver. Before moving it back, I rewrote the core data structures so that the work itself got much smaller, which is what the next two sections cover.

With the solver local, there’s no network round trip, no cold start, and no server bill. The cost is that the solve speed now depends on the user’s device, which is why the worker falls back to the CPU when a GPU isn’t available.

Bitmasks

The old Go solver stored letters as strings and answered every question by searching them. To test whether two letters sat on the same side, it went through the four sides and searched each side’s string for both letters. To test whether a solution used all twelve letters, it took each letter in turn and searched every word for it:

func (ls *LetterSquare) allLettersUsed() bool {
	for _, letter := range ls.letters {
		anyWordHasLetter := false
		for _, word := range ls.words {
			if strings.Contains(word, letter) {
				anyWordHasLetter = true
				break
			}
		}
		if !anyWordHasLetter {
			return false
		}
	}
	return true
}

There’s nothing wrong with this code, but the solver asks these questions millions of times. A puzzle has exactly twelve letters, and a 32-bit integer has room for all of them. Give each letter an index from 0 to 11, and represent “which letters does this word use?” as a number where bit i is set if letter i appears. That number is called a bitmask.

Combining two words’ masks shows which letters they use together:

Each of the twelve letters gets one bit. MORPHS and SINGLED each set their letters’ bits, and OR-ing the two masks sets all twelve.
A letter used twice in a word still sets just one bit.

With masks, “do these words use every letter?” becomes a single comparison:

(a.coverageMask | b.coverageMask) === allCoveredMask; // 0xFFF

The same idea handles sides. The solver precomputes a small table, sideOf[letterIndex], so “are these two letters on the same side?” is two array lookups and a comparison instead of eight string searches.

Adding a letter to a word’s mask only takes an OR of its bit, but removing a letter during backtracking is harder. If the word is “SEES” and you remove the last S, the S bit must stay set, because another S is still there. A mask records whether a letter appears, not how many times. So the solver rebuilds a word’s mask from its remaining letters whenever it backtracks, which is cheap because words are short.

Precomputation

The second idea is precomputation: anything that doesn’t depend on the search should be computed once, before the search starts.

The largest saving comes from the dictionary. The full word list has 26,648 words, but most of them can’t be used in any given puzzle. A word containing a letter that isn’t on the square can never appear in a solution. Neither can a word where two consecutive letters come from the same side. Filtering those out, along with words shorter than three letters, leaves a much smaller list.

For one puzzle, the three filters reduce the dictionary as follows:

The dictionary passes three filters: GO is too short, SNOW uses a letter not on the square, and GRID puts two same-side letters next to each other. MORPHS survives.
The counts are for this puzzle; each puzzle keeps its own few hundred words.

Only about 1 to 2% of the dictionary survives, and the solver searches only those words.3 For each surviving word, it works out up front the facts the search keeps asking about: which letters the word uses (as a bitmask), and which letters it starts and ends with. It stores them alongside the word:

interface ValidWord {
  word: string; // "MORPHS"
  coverageMask: number; // which puzzle letters it covers, one bit each
  firstLetterIdx: number; // which first-letter list it belongs to
  lastLetterIdx: number; // which list the next word must come from
}

A few more precomputations follow the same pattern:

Find Best on the GPU

With words precomputed and masks in place, Find Best can think in whole words instead of letters. A candidate solution is a chain of words, and extending a chain means appending a word whose first letter matches the chain’s last letter.

This problem suits a GPU well. A GPU is best at running the same small program thousands of times in parallel, each on different data, with no copy needing to talk to another. Checking whether one word extends one chain is exactly that kind of task.

One thread per pair

The WebGPU version launches one GPU thread for every (chain, word) pair, from a compute shader, a small program the GPU runs once per thread. In the first pass, each chain is a single word, so with 565 valid words that’s about 565 × 565 ≈ 320,000 threads. Each thread asks three questions:

  1. Does this word start with the chain’s last letter? If not, stop.
  2. Is this word already in the chain? If so, stop.
  3. OR the masks together. If the result covers all twelve letters, it’s a solution. Otherwise, if the chain is still short enough, it’s a candidate for the next pass.

In a single pass, each thread either stops early or writes its extended chain to one of two lists:

A grid of GPU threads, one per chain and candidate word. Mismatched first letters return at once; the rest OR their masks, recording full covers as solutions and dropping partial ones at the word limit.
Only four chains and five words are drawn; a real pass runs one thread for every pair.

The core of the shader, written in WGSL, WebGPU’s shading language, performs those three checks:

if (lastWord.lastLetterIdx != newWord.firstLetterIdx) { return; }

var nc = chain;
nc.words[chain.wordCount] = wordIdx;
nc.coverageMask = chain.coverageMask | newWord.coverageMask;
nc.lastWordIdx = wordIdx;
nc.wordCount = chain.wordCount + 1u;
nc.totalChars = chain.totalChars + newWord.charCount;

if (nc.coverageMask == uniforms.targetMask) {
  let slot = atomicAdd(&counts[0], 1u);
  if (slot < arrayLength(&solutions)) { solutions[slot] = nc; }
} else if (nc.wordCount < uniforms.maxWords) {
  let slot = atomicAdd(&counts[1], 1u);
  if (slot < arrayLength(&nextChains)) { nextChains[slot] = nc; }
}

Threads also need a safe way to write their results, because thousands of them may find a solution at the same moment, and they can’t all write to the same spot. Each one calls atomicAdd on a shared counter, an atomic operation that hands out slot numbers one at a time, guaranteeing no two threads get the same slot. Each thread then writes its chain into its own slot.

Pass by pass

The chains never leave the GPU, which keeps traffic between the CPU and GPU to a minimum. Each pass writes its new chains into a buffer that the next pass reads as its input, so nothing is read back or uploaded again. After each pass, the CPU reads back only the two counters, plus any solutions, in a single copy. The search goes level by level: all two-word chains, then all three-word chains, and so on. It stops at the first level that produces any solution, since those have the fewest words.

The GPU doesn’t pick the winner. Instead, once a pass returns its solutions, the CPU scans them once, keeping the one with the fewest total letters and breaking ties alphabetically. A single pass over the results is cheap for the CPU, and doing it on the GPU would add complexity for little benefit.

In a different puzzle, the first solutions only appear on the second pass:

The CPU uploads the words and one-word chains once. Each pass reads one chains buffer and writes the other. Pass 1 finds nothing, pass 2 finds eight, and the CPU picks CHAMBER REFUND DAWN.
Keeping chains on the GPU avoids copying thousands of them back and forth after every pass.

When there’s no GPU

Not every browser or device supports WebGPU. Without it, or when a pass produces more chains than the GPU’s buffers can hold, the worker runs the same search on the CPU. It uses the same filtered words, masks, and first-letter index, and it also goes one level at a time: all one-word chains, then all two-word chains, and so on up to five, stopping at the first level with a solution.

The CPU version also remembers dead ends, a form of memoization. A partial chain’s future depends only on three things: its last letter, the letters it has covered so far, and how many words it has left. Together, those three make up the chain’s position.

Once the search learns that a position is a dead end, it skips every later chain in the same position, however that chain got there. For example, if no single word can finish a chain that ends in E and covers C, E, A, M, and R, any other chain with that ending and those letters is skipped too.

The number of possible positions is small:

Multiplied together, that’s at most 196,608 positions. The solver stores one byte per position, so the whole table takes under 300 KB.4

The animation below shows one dead end being recorded and then reused:

At level 3, ARC → CAME ends in E with five letters covered and one word left. None of the 7 E-words completes it, so the position is marked dead; CREAM → MARE later reaches it and is skipped.
Dead ends are keyed by position, not by the words that led there.

Tradeoffs

The local architecture comes with a few tradeoffs:

Looking Back

Most of the speedup came from the algorithm, not from where the solver ran. The cloud function didn’t solve puzzles any faster. It kept the page responsive while the user waited on the network. Running in the browser again only worked because bitmasks and filtering had shrunk the work enough for the user’s own device.

Where the solver ran depended as much on what I knew as on what browsers offered. In 2022, it ran on the main thread and froze the page. In 2023, I reached for a server because I didn’t know Web Workers existed. By 2026, I did, and WebGPU had arrived to let Find Best run on the GPU.


  1. I also started a Rust version at the end of 2023, but abandoned it before it did anything useful. ↩︎

  2. Chrome shipped it in 2023, and Safari and Firefox followed in 2025. ↩︎

  3. For the three puzzles I checked, 255, 565, and 580 words survive. ↩︎

  4. Even a puzzle with no solution in five words is ruled out in a few milliseconds. ↩︎

  5. A readback copies the data into a buffer the CPU can map, then waits on mapAsync. Each one costs about 100 ms in Firefox on my machine. ↩︎

#Design #Engineering