/** I think we discovered the (now) New York Times word game Wordle around the same time as the rest of the (our) world. It was right before it moved to the New York Times and coincided with my wife reading an article suggesting there was evidence that daily mental exercise could help fight diseases causing dementia—specifically Alzheimer's.
My wife has never shown any interest in either puzzles or word games leading me to believe that doing them together daily would be the ideal mental exercise (more on that later). After a 182 day streak of doing Wordle every day we found out a couple of things. Firstly, my wife is very good at word puzzles and together we are even better at them, and secondly, we don’t like the pressure of having a “streak” to think about.
We consciously ended our streak and Wordle and the many other New York Times word games we had picked up along the way fell by the wayside.
One of them however—specifically the game Letter Boxed would intrigue me enough in moments of boredom to play once in a while.
The game (which you can see here): */
const ADDRESS = 'https://www.nytimes.com/puzzles/letter-boxed'
/** consists of 4 groups of 3 letters. How to play (from the NYT):
* Connect letters to spell words
* Words must be at least 3 letters long
* Consecutive letters cannot be from the same side
* The last letter of a word becomes the first letter of the next word eg. THY > YES > SINCE
* Words cannot be proper nouns or hyphenated
* No cussing either, sorry
* Use all letters to solve!
As much as I liked playing this game—on the odd occasion I come back to it—I felt like it would be a great candidate for a creative algorithm to solve it automatically. If I am honest there are some things in life I enjoy less than others and they include: being forced to be creative, algorithms, and sharing articles.
So in the spirit of “making” my wife play word games together for 180 days I decided to do all three of those things at once. The reason this article seems like it has a unique and different formatting is because it is in fact a JavaScript (with an algorithm) for Google Apps Script that automatically solves the Letter Boxed puzzle and emails a selection of results to myself. I’ll let you decide if it is creative.
Why Google Apps Script (GAS)? Most people have access to it—if you want to experiment yourself—it has very simple mechanisms for fetching remote data and sending email, and it can run unattended on a schedule. It is also free.
So first we make a function to run this, lets call it run (the function will need to be 'closed' with a corresponding `}` but we'll do that at the end (where it belongs)). */
function run() {
/** So there are other programs out there that solve this and most of them have a visual component ie: a web page so you can type in today’s letters for the possible solutions. They also all offer to automatically populate those letters by loading the data from the NYT in the background—which is what we are going to do here.
We grab (using the handy built-in function `UrlFetchApp.fetch()`) the HTML—which is the code used to render the NYT webpage—using the ADDRESS we defined earlier. Just like us (defining things once in the appropriate place) the people who designed and coded this game have a neat little <script> tag in the page with a variable called `gameData` that has all the information we will need to solve this. */
let html = UrlFetchApp.fetch(ADDRESS).getContentText();
/** We extract the part of the page we are interested in using a regular expression (a simple function for matching patterns of text). We are being super specific here and if NYT were ever to change anything about the structure of the game (even just putting a space before the word ‘window’) then this match would fail and the script would crash—because we are not checking for errors.
We might be tempted to add some error checking at this point—perhaps like the eventual puzzle solution we could have errors emailed to us—catching them neatly and making nice parsed messages to tell us what went wrong. As tempting as this is, we do not need to do it, if the script is set up to run automatically in GAS then Google will email us any time the script fails to run with the error that will also be stored in a log.
In addition, adding more code to check errors actually increases the likelihood of an error occurring (which would be (actually) ironic). So we will let the mechanism that was built to run the script and handle the errors handle the errors [^1].
Finally we take our extracted text—which is JavaScript—and slightly modify it before calling `eval` to essentially make the code from the page part of this script. We may be tempted to think this is a security issue if only because it certainly looks like, and could easily be one [^2].
In this case there is no executable script code in the data we have extracted. If there were in future then it would have to be targeted specifically for the technique we are using (which would likely crash the game), we trust NYT not to make changes like that (ie: they are trusted), and finally we have limited permissions when we run this and GAS has its own protections against these kind of things. */
let match = html.match(`.*<script type="text/javascript">(window.gameData.*?)</script>.*`);
eval(match[1].replace('window.', 'this.'));
/** So the ‘sides’ as the NYT call them are four groups of three letters defined like this `['ABC','DEF','GHI','JKL']`—we are going to turn this structure into a map that looks like this:
{
'A':['A','B','C'],
'B':['A','B','C'],
'C':['A','B','C'],
'D':['D','E','F'],
'E':['D','E','F'],
'F':['D','E','F'],
…etc
}
If you are asking yourself: doesn’t this have redundancy? Will this fail if a letter is repeated in a ‘side’? Or if you are saying: I already think I could do this much better?
My answers would be: Yes, but every variant of solution I could come up with ended up with this data structure either in multiple variables (for specific uses) or creating those structures on the fly when I executed the solution (which also took longer).
Yes, this will fail if a letter is repeated, but it is unclear what the intended result was to be as this would functionally be against the implied rules of the game (it is not explicitly stated). I may revisit this later—this being one of those development tasks that you had a rationale to address at a later date.
Yes, there are other solutions to this: this one is mine—and now I have sat with it for a while—I have nothing but curiosity for other solutions, I’d love to see yours. */
const map = gameData.sides
.map(v => v.split(''))
.reduce((p, v) => Object.assign(p, ...v.map(c => ({ [c]: v }))), {});
/** Next we use the DRY (Don’t Repeat Yourself) principle and define a function that we will reuse to determine how many unique letters we have in a word. And in this case we made a function capable of accepting a single word ‘bob’ (result: 2) or an array (list) of words ‘bob’,’bab’ (result: 3). This might not be a good general coding practice but in this case I was fascinated by the idea (creatively) of taking a good principle like DRY too far.
And speaking of DRY we define a variable here that gives us a target number of letters we need to use to complete the puzzle. Why not just hard code 12 (3x4)? Technically this would work for a puzzle with 4 letters per side. Not that we may have to worry about that but defining the variable this way leaves no question as to why it has that value. */
const uni = (s) => [...new Set(''.concat(...s).split(''))].length;
const TARGET = Object.keys(map).length;
/** So now we need a starting list of words for our program to select from. I discovered when examining the `gameData` on the original page that it included a ‘dictionary’. This would have been an ideal starting point (and for a time I used it) until I realized it already only contained valid words for the current puzzle (which seemed like too much of a shortcut) [^4].
So instead I found what I thought was a reasonable list of around 90K words and hosted it on Github so GAS can `fetch` the content.
Here we read the text content, make it a list (split it by lines), filter it to 3 or more letters (remember the rules of the puzzle) and then make all the words UPPERCASE. This txt file could of course be pre-optimized but we may reuse this list later for other purposes. */
const val = UrlFetchApp
.fetch('https://raw.githubusercontent.com/json-k/letterboxed-solver/refs/heads/main/anagram_dictionary.txt')
.getContentText()
.split('\n')
.filter(w => w.length >= 3)
.map(w => w.toUpperCase())
/** Now we can use our map—which we created from the ‘sides’ to filter our wordlist to only valid words. This is done simply by filtering every letter of every word and deciding if the letter is in the map (ie: is it one of our letters) and then: is the next letter not in its mapped array (which also handles the case of duplicate letters)? If the filtered word is the same length as the original word it must be valid.
Then we `sort` this list using our ‘uni()’ (unique) count so that the words using the most letters from the original sides will be listed first. */
.filter(w => w.split('')
.filter((v, i, a) =>
map[v] && !
map[v].includes(a[i + 1]))
.length == w.length)
.sort((a, b) => uni(b) - uni(a));
/** Next we define a list of our possible solutions, which will be a list of lists. The reason we are defining the value here is that as soon as we have found a valid solution we will stop searching and email the answer to the user.
Now if you are thinking “that seems fair enough”—there is a good chance you are a Developer. If you are thinking “perhaps we need to find all the solutions so we can optimize”—you might be a Data Scientist. If you are thinking “Wait, what did we say this thing does again?”—there is a chance you might be a Product Manager. */
let sol = [];
/** We are going to dispense with our mapping, filtering, and reducing (mostly) and use a ‘labeled block’. Once we have found our solution we will be able to ‘break’ at this block specifically and continue our program. */
searching: {
/** Loop through every valid word setting up a variable (pos) to be our possible solutions: */
for ( i in val ) {
let pos = [[val[i]]];
/** Here is the part where we actually solve the puzzle (finally right?).
We are going to check each of our valid words (remember they are ordered with the most matching letters first) and we are going to attach every other valid word according to rules of the game ie: the first letter of the next word is the last letter of the previous word.
We are going to do this as many times as the ‘par’ score for the current game (`gameData.par`) because we can assume that the solution can at least be completed in this many words. In practice the solution is often within two words but we should not assume this.*/
for ( j = 0;j <= gameData.par;j++ ) {
/** We check the words BEFORE we attach their possible matches to catch the case where our first word actually contains all the letters from every side (which may happen). We check by filtering our possible solution list to only values that have all the unique letters (the value of TARGET).
If there are any results we have solved the puzzle and can continue. */
if ((sol = pos.filter(v => uni(v) >= TARGET)).length > 0 ) {
break searching;
}
/** This line finds all of the values that begin with (`w.slice(0, 1)`) the last letter of the last word (`v.at(-1).slice(-1)`) of our possible solutions and and joins them together if that is the case. There is an additional check to ensure that we do not accidentally attach the same word (in the case where it begins and ends with the same letter). */
pos = pos.map(v => val.filter(w =>
(!v.includes(w)) &&
w.slice(0, 1) == v.at(-1).slice(-1))
.map(w => ([...v,w])))
.flat();
/** So on the next loop the `if ((sol = pos.f…` line will check if any of these solutions actually solve the puzzle. Because we are adding new values AFTER we check we ensure that our loop `for ( j = 0;j <= gameData.par;j++ )` executes one more time than we’d expect ie: a par 3 should run 4 times due to our order of operations. */
}
}
}
/** So when we reach here—which will be when we have either broken out of the ‘searching’ block or checked every possible solution (which would take a while)—the value of the variable `sol` will be a list of possible solutions for the puzzle. With the possibility of there being no solution.
We can then email the result(s) to the user: (which GAS makes very straightforward) */
MailApp.sendEmail({
/** If that user is you and you want to run this script / article. Then you will need to update this value with your email address. This is not in a configuration at the top of the script because it appears in here only once and I am explicitly saying it needs to be changed here: */
to: "you@yourdomain.whatever",
/** GAS has quite a few tools for dealing with email templates but here we have kept it simple (LOL) and substituted values into a Template Literal String. Using a GAS utility to format the date for our subject line. */
subject: `Letter Boxed : Solution for ${Utilities.formatDate(new Date(), 'US/Eastern', 'yyyy-MM-dd')}`,
htmlBody: `
<html><head><base target="_top"></head><body>
<h1><[°°]> *Beep *Boop...Etcetera (from your script)</h1>
<p>The answer to today's <a href="${ADDRESS}"
style='color:#ff3300'><b>Letter Boxed</b></a>
according to...well, you is:</p>
${sol.map(s => s).reduce((p,c) => (p=p+'<p><code>'+c.join(',')+'</code></p>',p),'')}
<p>The letters were <b>${gameData.sides.join(', ')}</b>
and the New York Times say their solution is:
<code>${gameData.ourSolution.join(',')}</code></p>
</body></html>`
});
/** And now we finally close our function, our script is complete. */
}
/** And we can wrap up this article.
If you want to run this code yourself you can paste the entire article into the Google Apps Script editor and run it. It can be scheduled to run daily if you want to keep testing it / seeing the results.
For those of you who are not familiar with development and writing code—the actual code in this article took me around 45-60 minutes to write over about 3 sessions. I only included about half the narrative about the decisions I made while writing (to avoid it being overwhelming).
I spent around 3-4 hours thinking about how to actually do this—which might be referred to as ‘development’ as opposed to ‘coding’.
If you think that it seems like a lot of time or thought for something this straightforward then you’d be mistaken—good software development and coding requires this much thought and time and often much more. This is likely the reason that developers and engineers get “that certain look” when somebody talks about AI writing code.
How that will work out in the future I do not know.
But right now I am sure of a couple of things—my brain feels happy but exhausted and I’ll never encourage my wife to do the crossword again.
In Part 2 of this article we'll discuss why this solution does not work, and likely never will (yes, you read that correctly).
-----
Footnotes:
[1] I love the sentence “...and handle the errors handle the errors”—because it seems grammatically incorrect but isn’t and it also makes the point concisely. If you have to say the same thing twice to explain it then what you should do becomes obvious.
[2] It is always fascinating how easily a single line or action in a piece of code can become a large security issue—are we sure we want AI to be vibe coding for us?
[3] In fact this data structure has so much redundancy I had to use the spread `...` operator to prevent a circular reference.
[4] The gameData variable also contains a value called `ourSolution` too but that would not really be a challenge. */

Letter Boxed : Solver : Part 1
By :
Share :

Leave a Reply