Logo

::<lexlblog>

I wrote a solver for squares.org.

I like a quick puzzle. Solving riddles keeps your brain in shape, and coming up
with solutions to problems also just feels very satisfying to me.

I like doing a quick
[wordle](https://wordly.org/),
but I also like my
[Arukone](https://gridgames.app/arukone/)
or a quick
[Loopy](https://gridgames.app/loopy/).
And it's also fun to guess a country
[on the globe](https://globle.org)
or
[by shape](https://worldlegame.io/)
every now and then. And I also like playing around with
[combining words](https://combinations.org/).

So naturally I was immediately attracted by the
[Squares](https://squares.org/)
game. It works like this:
You've got a 4 x 4 grid of letters. Using your cursor you can drag accross the
grid to form words. A connection can be made from a letter to any adjacent
letters. Pretty easy, right?

Not quite. You see: Sometimes there are upwards of 60 intended solutions! Why
intended? Because there are also many bonus words you can create. And that's
just too much for me. I would claim my English is pretty reasonable (bold
statement, right?), but I am not a native speaker. Some of those words I've
never heard of. But even if the level of difficulty would be much lower, it's
still **a lot** of words to find. It just takes a long long time to do it.

Subsequently, as someone who also likes to write computer programs, I had to
take on the challenge to solve the squares once and for all.

Meet **the squares solver**:

![Figure [solver_first]: Entire run of a simple square.](media/squares_solver_running_first.mp4)

This shows an entire run of the solver. It really is just a Python script that
moves your mouse for you. And timing is pretty lax. Here is the code:

``` python
import sys
import time
import random
from pynput.mouse import Button, Controller


def main():
    DELAY = 0.2
    sequences = []

    grid = sys.argv[4].lower()

    graph = generate_graph(grid)

    with open("words.txt", "r", encoding="utf-8") as ifile:
        wordlist = ifile.readlines()

    wordlist = [word.lower().strip() for word in wordlist]
    wordlist = [word for word in wordlist if len(word) >= 4]

    for word in wordlist:
        sequence = solve_for_word(word, graph, grid)

        if len(sequence) == 0:
            continue

        sequences.append(sequence)

    # print some statistics
    print(len(sequences), "words found")

    duration = 5 + 5  # initialization
    duration += len(sequences) * 2 * DELAY  # press and release
    duration += sum([len(s) for s in sequences]) * DELAY  # dragging time
    duration += len(sequences)  # interval between words

    t_min = int(duration / 60)
    t_sec = int(duration - 60 * t_min)
    print(f"input will take around {t_min}m {t_sec}s")

    # shuffling
    for _ in range(len(sequences)):
        i1, i2 = (
            random.randrange(0, len(sequences)),
            random.randrange(0, len(sequences)),
        )
        sequences[i1], sequences[i2] = sequences[i2], sequences[i1]

    print("hit any key to start input...")
    input()
    run_input(sequences, DELAY)


def run_input(sequences, delay):
    origin = (int(sys.argv[1]), int(sys.argv[2]))
    size = int(sys.argv[3])
    step = size // 4

    mouse = Controller()

    time.sleep(5)  # give user time to playe mouse on upper left field

    mouse.click(Button.left)  # focus application
    time.sleep(5)  # give user time to accept input in Wayland

    for sequence in sequences:
        sx, sy = sequence[0] % 4, sequence[0] // 4
        move_to_field(mouse, sx, sy, origin, step, delay=delay)
        start_drag(mouse, delay=delay)

        for i in sequence[1:]:
            sx, sy = i % 4, i // 4
            move_to_field(mouse, sx, sy, origin, step, delay=delay)

        end_drag(mouse, delay=delay)
        time.sleep(1)

    return False  # this ends the listener thread


def move_to_field(mouse, x, y, origin, step, delay=0.2):
    mouse.position = (origin[0] + step * x, origin[1] + step * y)
    time.sleep(delay)


def start_drag(mouse, delay=0.2):
    mouse.press(Button.left)
    time.sleep(delay)


def end_drag(mouse, delay=0.2):
    mouse.release(Button.left)
    time.sleep(delay)


def solve_for_word(word, graph, grid):
    paths = [[i] for i, c in enumerate(grid) if c == word[0]]

    for next_char in word[1:]:
        next_paths = []

        for path in paths:
            current_field = path[-1]
            neighbors = graph[current_field]
            if next_char in neighbors:
                next_paths.append(path + [neighbors[next_char]])

        paths = next_paths

    paths = [p for p in paths if len(set(p)) == len(p)]

    if len(paths) > 0:
        return paths[0]

    return []


def generate_graph(grid):
    graph = {}
    for i, C in enumerate(grid):
        neighbors = {}

        for k, N in enumerate(grid):
            if k == i:
                continue

            # check if N is a neighbor of C

            C_row, C_col = i // 4, i % 4
            N_row, N_col = k // 4, k % 4

            diff_row = abs(C_row - N_row)
            diff_col = abs(C_col - N_col)

            if diff_row <= 1 and diff_col <= 1:
                neighbors[N] = k

        graph[i] = neighbors

    return graph


if __name__ == "__main__":
    main()
```

I know, that code is pretty ugly. But remember: It's just a little Python script
that I threw together in about an hour. It doesn't need to be pretty, it just
needs to work. And it does work, as you could just see.

The idea is really simple:

First, I got a list of english words. I found
[this one](https://github.com/dwyl/english-words),
which is publicly available and seemed to be rather complete.

The code then loads these words and reshapes them to make them as identical and
consistent as possible. With all words now having a similar form (lowercase
letters, no whitespace, etc.) the script iterates over all words and checks if
that word could, in theory, be created by dragging accross the square - this
yields the candidates to test.

The rest of the program is just a lot of boilerplate to make the mouse
movements.

On my setup I am launching the program using a little bash script, since the
Python program itself also takes a few additional parameters for screen size,
etc. This is the execution script:

``` bash
#!/bin/bash

source env/bin/activate

# 1st monitor config
python good_solve.py 1265 410 530 "$1"

# 2nd monitor config (2nd monitor vertical)
# python good_solve.py 1265 905 530 "$1"
```

Execution is then really simple (shown for the square above):

```
$ ./run_square_solver domanjwsaedvdtta
```

!!! Tip
    The Python module `pynput` which is used to move the mouse around is loaded
    by sourcing the venv environment. Module hygiene is really important these
    days.

The solver doesn't completely solve all words, but I still was able to complete
the square by hand:

![Figure [squares_solved]: Fully solved square.](media/squares_solved.png)

And yes, I deliberately took that screenshot while the confetti was still
drizzling over the square.

But that was an easy one! Now what about a difficult square? Well, just watch:

![Figure [solver_second]: Entire run of a difficult square.](media/squares_solver_running_second.mp4)

It got 56 words out of 60 - not too bad!

Unfortunately I was only able to guess one more word "HERE", leaving me with
this unsolved square right here:

![Figure [squares_unsolved]: Unsolved square. Only 3 words are left, but I can't figure out which.](media/squares_unsolved.png)

It's a shame. But this square is just too tough for me. Maybe you were able to
solve it?