Codenames · clue generator v2

I bet a graph would beat embeddings.

In v1 I built a Codenames spymaster assistant out of word embeddings and cosine similarity. It worked okay, and at the end I named the weakness. Embeddings give you a vague semantic cloud rather than a concrete connection you can point at. I said a graph would fix that. So I built one, out of the whole Wikipedia link graph in Neo4j, and benchmarked it against a GloVe baseline and a pure LLM on real games. The graph turned out fast, auditable, and free to run. It also has a yield gap I could not close, and the boring LLM does the job. The figures below run live in your browser.

scroll
A connection you can point at

Two words are related if they share a Wikipedia article.

The problem with the embedding approach was that it could not explain itself. Cosine similarity would rank OCEAN and SHARK as close, but it could not tell you why, and it fell apart on proper nouns and on polysemy, words that carry more than one meaning, like BANK for a riverbank or the place that holds your money. What I wanted instead was a connection I could trace. SHARK and OCEAN both link to the Wikipedia article for Marine biology, so "marine" is a candidate clue that bridges them.

That is the entire idea. Pick the board words on your team, find the Wikipedia articles that several of them link to, and the best of those shared neighbors is your clue. The more team words a single article bridges, the better the clue. Toggle the team words below and watch the shared-neighbor clue fall out.

Shared neighbors · pick your team wordsbest clue
clue bridges 0 team words score
Blue nodes are your selected board words; the boxes above are Wikipedia articles that at least two of them link to. The generator scores every shared neighbor and picks the one that bridges the most team words, tie-broken by how specific the article is. Turn words on and off and the winning clue changes with them.
The graph pipeline

Wikipedia is already a graph, so you only have to query it.

A graph database stores data as nodes and edges instead of rows and columns. For Wikipedia that mapping is free: every article is a node, every hyperlink is an edge. The full English graph is about 13.6 million nodes and hundreds of millions of edges, and it lives in Neo4j on my machine.

You query it by describing the shape you want and letting the database find every match. In Cypher, Neo4j's query language, (a)--(b) means "a connected to b by any edge," and you can chain it. So "find every article that both SPACE and ROCKET link to" is almost literally the query:

(space)--(candidate)--(rocket)

Everything else is filtering and scoring bolted onto that one pattern. Not every article makes a good clue, though. "List of mammals by country" links to a great many things and is useless as a clue, so I precompute a GoodClue label on the articles that pass a degree filter (200 to 10,000 links) and some title-pattern filters, once, offline. Then a clue's base score is -log10(degree) * 25 + 10. Articles with middling connectivity score highest, because they are specific enough to be useful but connected enough to bridge more than one word.

Making it fast

The first version averaged five seconds, and thirty-two on a bad board.

The first working generator averaged 4.9 seconds a board, and a board with a high-degree word like "gold" or "lead" could take 32 seconds while the query walked an enormous neighborhood. Three changes, applied together, took the average down to 40 milliseconds and made the worst case disappear. Each one moves work the board doesn't need out of the per-request path.

Flip them on one at a time below.

Query latency · three fixes4.9 s
average4.9 s
worst case32 s
Bars are log-scaled against the 32-second worst case. The per-fix numbers are a model of each change's contribution; only all three together reproduce the measured 4.9 s → 40 ms. Precompute moves 13 title-pattern filters and a log-degree penalty out of the hot path. The LIMIT 150 stops the opponent-overlap scoring from running on thousands of candidates. The lower cutoff keeps high-degree words like "gold" from triggering the giant traversal that caused the 32-second outliers.
Evaluating against real games

Three generators on the same benchmark.

To find out whether any of this was good, I ran three generators against 50 real boards from the SALT-NLP dataset of human-labeled Codenames games: the Neo4j graph, the original GloVe embedding baseline, and a pure LLM that gets the whole board handed to it. I tracked yield (did it produce a clue at all), precision against the human clue's targets, single-word rate as a proxy for legality, and latency.

40 ms
graph latency per board, no API calls, free after setup
68%
graph yield, empty on nearly a third of boards
~100%
pure-LLM yield, at 2 to 5 seconds and a fraction of a cent
The three approaches · where each one wins
DimensionGraph (Neo4j)Pure LLM (Gemini)GloVe
Yield68%~100%86%
Avg latency40 ms2–5 s12 ms
Precision@K0.9410.965*
Legalitystrongweakmedium
Polysemynoyespartial
Runtime cost~$0~$0.002–0.005$0
* GloVe's Precision@K is inflated. The SALT dataset stores team words as targets, so any generator that returns team words scores near 1.0. The graph wins on speed, cost, and legality; the LLM wins on the two things that decide whether you get a clue at all, yield and polysemy.
What I got wrong

I assumed the yield gap was a mapping bug, but it wasn't.

That 68% bothered me. My first assumption was that board words like "sub" or "boom" weren't matching Wikipedia titles, so I built a whole WikipediaWordMapper with full-text search, embedding rerank, and an LLM disambiguation call to resolve them. I wired it in, re-ran the eval, and yield came back unchanged at 68%.

The words map fine. "Sub" resolves to the "Sub" article without any help. The problem is what comes next. "Sub" has 40 Wikipedia links total, and none of its neighbors clear the GoodClue floor of 200. The query returns nothing because there is nothing specific enough to bridge from. So I tried the obvious knob, lowering the floor. Drag it and watch what happens to the three numbers that matter.

Lowering the GoodClue floor · the fix that wasn'tfloor 200
candidate nodes 950k yield 68% avg latency 40 ms
Dropping the floor from 200 to 50 adds about 200k candidate nodes and multiplies latency roughly thirteen-fold, and yield does not move. The new low-degree nodes are exactly the ones too sparsely connected to bridge anything, so they cost traversal time and return no extra clues. I reverted the change. The gap is structural for this dataset. Many boards use common words whose Wikipedia pages are stubs, and the graph has nothing to work with.
The boring baseline that won

The pure LLM gets the board and answers.

Given the gap, I added a pure-LLM baseline. Hand the whole board to Gemini with a prompt asking for the best clues, and parse the JSON it returns. It handles polysemy without being told to, because it knows BANK means money in a financial board and a river otherwise. It writes clues a person would use, and by construction it always returns something. The costs are real: 2 to 5 seconds a board, no way to verify why a clue connects, and the occasional confident hallucination. But structured reasoning over a small fixed set of words is what these models are now good at.

What I'd ship

Prompt the LLM, and keep the graph for when you need to explain why.

If you want a Codenames clue generator that works and you can spend a fraction of a cent per board, use the LLM. It has full yield, writes natural clues, handles proper nouns and slang, and needs nothing but an API key.

The graph is worth keeping when you need to know why a clue connects. You can trace the exact Wikipedia articles that link a clue to each team word, which makes the system tunable and auditable in a way the LLM is not, and it runs in 40 milliseconds with no API bill. The right setup uses both. Run the graph first, and fall back to the LLM on the boards where the graph comes up empty. That gets you close to full yield while keeping the fast, traceable path for the boards where it works.

The verdict

I set out to prove the graph would win and it didn't, quite. It is the better engineering artifact and the worse product, and for most people the answer is to prompt the model and ship it. The code for all three, plus the eval harness, is on GitHub.

Orbitope · 2026