Skip to content

Commit 8ac3a15

Browse files
committed
add post
1 parent 821e5d7 commit 8ac3a15

File tree

5 files changed

+168
-2
lines changed

5 files changed

+168
-2
lines changed

components/image.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import siteConfig from "../siteConfig.json";
22

3-
import Image from "next/legacy/image";
3+
import Image from "next/image";
44

55
function imageResize(imageWidth, imageHeight) {
66
let width, height;
@@ -20,7 +20,12 @@ export default function SpacedImage(props) {
2020

2121
return (
2222
<span className="spacer">
23-
<Image {...rest} width={width} height={height} />
23+
<Image style={{
24+
display: 'block',
25+
marginInline: 'auto',
26+
maxWidth: '100%',
27+
height: 'auto',
28+
}} {...rest} width={width} height={height} />
2429
<style jsx>{`
2530
.spacer {
2631
padding-top: 16px;

posts/solving-queuedle.md

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
---
2+
title: "Solving Queuedle"
3+
date: "2025-05-31"
4+
tags: ["javascript"]
5+
description: "Writing a game solver for Queuedle."
6+
---
7+
8+
I've written a solver for my word-sliding puzzle game called [Queuedle](https://queuedle.com/). Usually when I write solvers, I first need to simulate the game, so it was fun to start with those parts already written!
9+
10+
In case you missed [my last post](https://healeycodes.com/how-i-made-queuedle) where I covered the development process of the game, I've included a screenshot below. You play by inserting letters from a queue into rows and columns, trying to make as many words as possible.
11+
12+
![A game of Queuedle being played.](overview.png)
13+
14+
## Search Space
15+
16+
When trying to solve Queuedle, we can't use brute force because, like chess, the search space is too large. However, as we explore the graph of game states we *can* use a heuristic to prioritize nodes that are more likely to lead to a node with a high score.
17+
18+
In Queuedle, there's a 5x5 grid of letters:
19+
20+
- 5 rows and 5 columns
21+
- For each row: 2 possible moves (left, right)
22+
- For each column: 2 possible moves (left, right)
23+
- Total possible moves per state (before restrictions):
24+
- (5 rows × 2) + (5 columns × 2) = 10 + 10 = 20
25+
26+
At the start of the game, the branching factor is 20 (compared to chess, where the average branching factor is ~35). It drops when moves get restricted (rows and columns may only slide one way).
27+
28+
The search space is in the quintillions. With a letter queue of 15 and a branching factor starting around 20, we're looking at roughly 20^15 ≈ 3×10^19 possible move sequences (including duplicate board states).
29+
30+
Queuedle is a lot easier to write a solver for compared to chess though. It's easier to know if one game state is better than another by getting the board's score (counting the letters of all the words).
31+
32+
This is a clear, objective measure of "goodness" for any state. Unlike chess, there isn't an opponent planning complex tactics. The per-move heuristic for chess needs to consider material, position, king safety, pawn structure, threats, etc.
33+
34+
Let's define our heuristic in a sentence: a Queuedle board with a higher score is more likely to lead us to another board with an even higher score.
35+
36+
The search will never end so we need a finishing criterion. We could use total game states seen, wall clock time, or a certain score. I'll use total game states seen and set it to 500k.
37+
38+
This algorithm is called [best-first search](https://en.wikipedia.org/wiki/Best-first_search):
39+
40+
> [Explore] a graph by expanding the most promising node chosen according to a specified rule.
41+
42+
As we explore, we need to keep track of:
43+
44+
- A priority queue of nodes
45+
- Sorted by our heuristic
46+
- Best node so far
47+
- We report this after checking 500k nodes
48+
- The board grids we've already visited
49+
- Avoid duplicate work and graph cycles
50+
51+
I've put together an example journey below.
52+
53+
```tsx
54+
START (score:0, queue:15)
55+
56+
├───[left 0] ············(s:6, q:14)
57+
├───[right 0] ············(s:3, q:14)
58+
├──┬[down 1] ···········(s:22, q:14)
59+
│ ├───[left 0] ········(s:15, q:13)
60+
│ ├───[right 0] ········(s:25, q:13)
61+
│ └──┬───[down 4] ·····(s:26, q:13)
62+
│ ├───[right 0] ·····(s:29, q:12)
63+
│ └───[left. 4] ·····(s:32, q:12) ★ best
64+
├───[up 4] ·············(s:3, q:14)
65+
├───[down 4] ·············(s:7, q:14)
66+
│..
67+
68+
Priority Queue: [(s:35,q:11), (s:31,q:12), (s:28,q:11), ...]
69+
Visited : {hash(grid of l0), hash(grid of r0), ... }
70+
Best node : (s:32, q:12) via [down[1] → down[4] → left[4]
71+
```
72+
73+
Notice how we walk down a leaf seeking out a higher scoring board and then go back up. This is because, after hitting the best node so far, all the *next* possible moves for that board were lower scoring than the next items in the priority queue.
74+
75+
When I got my solver working, it took a few seconds of compute time to *double* my score for that day's Queuedle (my personal Deep Blue moment).
76+
77+
I went and replayed the moves that the solver printed out. Similar to when you copy a chess engine's moves, the decisions didn't make sense to me. They were robotic. Before the solver's final move, I was looking at a board with a score of `50` (which is `11` higher than my all time score), and I couldn't see the next move *at all*. Even with an hour, and a Scrabble dictionary, I might not have gotten it.
78+
79+
And then the solver's final move took the score from `50` to `70`!
80+
81+
![A game of Queuedle with a score of 70.](70.png)
82+
83+
I'll show the core function of the solver here. It has been optimized for readability (not performance).
84+
85+
```tsx
86+
// Best-first search for Queuedle
87+
export function bestFirstSearch(
88+
initialState: GameState,
89+
maxNodes: number = 500_000
90+
): { bestState: GameState; moveSequence: Move[] } {
91+
let nodesExpanded = 0;
92+
const initialNode: Node = {
93+
state: initialState,
94+
moves: [],
95+
score: evaluateState(initialState)
96+
};
97+
98+
// Three key data structures for best-first search
99+
const queue = new PriorityQueue<Node>(); // Nodes sorted by score
100+
const visited = new Set<number>(); // Avoid cycles
101+
let bestNode = initialNode; // Track best node found so far
102+
103+
queue.enqueue(initialNode, initialNode.score);
104+
105+
// Always explore the highest-scoring node next
106+
while (queue.length > 0 && nodesExpanded < maxNodes) {
107+
const node = queue.dequeue()!;
108+
nodesExpanded++;
109+
110+
if (node.state.score > bestNode.state.score) {
111+
bestNode = node;
112+
}
113+
114+
// Skip if we've already explored this state
115+
const hash = hashState(node.state);
116+
if (visited.has(hash)) {
117+
continue;
118+
}
119+
visited.add(hash);
120+
121+
// Generate all possible next moves and add to queue
122+
for (const move of getValidMoves(node.state)) {
123+
const nextState = handleSlide(node.state, move.direction, move.index);
124+
const score = evaluateState(nextState);
125+
queue.enqueue({
126+
state: nextState,
127+
moves: [...node.moves, move], score
128+
}, score);
129+
}
130+
}
131+
132+
return { bestState: bestNode.state, moveSequence: bestNode.moves };
133+
}
134+
```
135+
136+
## Adding Optimizations
137+
138+
Solvers can always be improved. Even state-of-the-art ones. Their code can be optimized, or ported to new CPU architectures, but most of all, they can be smart and do less work. Every year, the Elo of the best chess engines goes up a few points.
139+
140+
The first optimization I tried was to come up with a better heuristic. While the score of a board is a good signal, a better signal is `score + potential`. We should prefer to explore nodes which have a high score *and* more future moves available.
141+
142+
Luckily, this was a one-line change:
143+
144+
```tsx
145+
// Generate all possible next moves and add to queue
146+
for (const move of getValidMoves(node.state)) {
147+
const nextState = handleSlide(node.state, move.direction, move.index);
148+
149+
// Consider remaining moves!
150+
const score = evaluateState(nextState) + (15 - node.moves.length);
151+
```
152+
153+
With this change, and maintaining the same finishing state of 500k game states, the solver was able to find a board with a score of `81` (up from `70`).
154+
155+
![A game of Queuedle with a score of 81.](81.png)
156+
157+
Next, I tried including the restrictions (rows and columns may only slide in one direction over the course of a game) as part of the heuristic. My thinking was that a board with *more* restrictions is *worse* because it has less potential. However, this did not increase the maximum score it was able to find. In fact, it decreased it.
158+
159+
More failed ideas involved giving higher evaluation scores to boards with better letters (meaning the ability to be part of many different words), as well as letter positioning (vowels in the center of the board, and consonants around the edges).
160+
161+
Later, I'm keen to port the code from JavaScript to a faster language. Possibly with speed optimizations too – like [bitboards](https://healeycodes.com/visualizing-chess-bitboards). With my heuristic optimization, I didn't expect the jump in score from `70` to `81`, and now I want to see how high it can go by searching more game states.
50.9 KB
Loading
54.1 KB
Loading
47.2 KB
Loading

0 commit comments

Comments
 (0)