Scrabble Solver

Making a Scrabble Bot

Say you’re playing Scrabble and the words just aren’t coming to you. Or maybe you’re overwhelmed with possibilities and just can’t figure out the best play.

If you like to cheat at Scrabble, you will surely be aware of excellent tools such as Merriam-Webster’s Scrabble Word Finder that take a Scrabble rack and tell you the words you can make from them. Webster is a great resource, but it still doesn’t tell you the best play in the game you’re playing right now. What if you could input the game board and your rack then ask a robot what the highest scoring play is?

So then, let’s make a bot that’s disgustingly good at Scrabble. If you want to play yourself, the code is on my Github.

Game Logic #

Before making our bot, we need to make the game. Scrabble-likes consist of a set of tiles with point values, a board with multipliers for the tiles, a bag to draw from, some number of racks to play from, and a dictionary of valid words. Players take turns playing a single “main” word either down or across that abuts at least one other word on the board. Both the main word and the resulting crossings must be valid words for the play to count.

The abstractions we can use to help implement the game are a Board that stores game states, and a Bag that stores tiles in a random order. We can use a simple vector to represent the players’ racks.

For Bag we need a stack of tiles to pull from from and a random number generator rng to shuffle the bag.

pub struct Bag {
    pub tiles: Vec<char>,
    rng: rand::rngs::ThreadRng
}

Board chiefly needs the grid to store spaces, as well as some other handy data that we’ll use later.

pub struct Board {
    board: Vec<Vec<Space>>,
    staged_spaces: Vec<(usize, usize)>,
    neighbors: HashSet<(usize, usize)>,
    word_list: Vec<String>,
}

The Space structure needs to record the letter, multipliers, and the value of the tile occupying the space. We can use a non-letter placeholder char such as '-' to signify the space is empty.

pub struct Space {
    tile: char,
    letter_mult: i32,
    word_mult: i32,
    val: i32,
}

One turn of our program consists of two steps: a staging step, followed by a submitting step. During staging, the player is allowed to place tiles from their rack onto the board. Almost nothing related to placement or word valididty is disallowed at this step. The only rules are that the tiles placed must come from the player’s rack and that the tiles are placed within the bounds of the board. During submitting, the tiles on the board are evaluated for legality. If they are legal, they are accepted and scored. Otherwise they are rejected and returned to the player’s rack. Separating the logic of the placement of tiles from the acceptance of submissions makes the task of writing both more manageable.

Let’s flesh out these evaluation rules. We can make a list of criteria in natural language and later translate each step to code. A submission is valid if and only if:

1.
    a. There is only one staged tile OR
    b. All staged tiles have one fixed dimension 
    (they all go up/down or left/right) 
AND
2. At least one staged tile is adjacent to an already-accepted word
AND
3. All staged tiles are part of the same main word
AND
4. The main word is legal
AND
5. All crossings between staged tiles and already-accepted 
tiles form legal words

This is a great exercise that breaks down a big problem into a few manageable steps. Also, all these steps can individually be done in linear time, which is great news! To get a taste of the logic, let’s see a code snippet for number 2.

// check contiguity
if const_row {
    let leftmost = self.get_leftmost_col(tile1_row, tile1_col).unwrap();
    let rightmost = self.get_rightmost_col(tile1_row, tile1_col).unwrap();


    for space in &self.staged_spaces {
        if space.1 > rightmost || space.1 < leftmost {
            println!("Non-contiguous submission because {} not in [{} {}].", space.1, leftmost, rightmost);
            return false;
        }
    }
}

In this example, we’ve already determined that the word is going across, i.e. it’s on a constant row. We now find the bounds of the word containing some tile in the staged spaces. Then, if any staged space falls outside of those bounds, we reject the submission, because we’ve proven that not all tiles are part of the same word.

We can implement a check for each of the rules in this manner. Once we are done, we can write some scoring logic and boilerplate shell code to let us play! Let’s see what that looks like.

New scrabble game!
There are 98 tiles in the bag.
Score: 0
00 01 02 03 04 05 06 07 08 09 10 11 12 13 14
00 tw -- -- dl -- -- -- tw -- -- -- dl -- -- tw
01 -- dw -- -- -- tl -- -- -- tl -- -- -- dw --
02 -- -- dw -- -- -- dl -- dl -- -- -- dw -- --
03 dl -- -- dw -- -- -- dl -- -- -- dw -- -- dl
04 -- -- -- -- dw -- -- -- -- -- dw -- -- -- --
05 -- tl -- -- -- tl -- -- -- tl -- -- -- tl --
06 -- -- dl -- -- -- dl -- dl -- -- -- dl -- --
07 tw -- -- dl -- -- -- dw -- -- -- dl -- -- tw
08 -- -- dl -- -- -- dl -- dl -- -- -- dl -- --
09 -- tl -- -- -- tl -- -- -- tl -- -- -- tl --
10 -- -- -- -- dw -- -- -- -- -- dw -- -- -- --
11 dl -- -- dw -- -- -- dl -- -- -- dw -- -- dl
12 -- -- dw -- -- -- dl -- dl -- -- -- dw -- --
13 -- dw -- -- -- tl -- -- -- tl -- -- -- dw --
14 tw -- -- dl -- -- -- tw -- -- -- dl -- -- tw

Rack: A C I M N O U
> wa manic 7 7
00 01 02 03 04 05 06 07 08 09 10 11 12 13 14
00 tw -- -- dl -- -- -- tw -- -- -- dl -- -- tw
01 -- dw -- -- -- tl -- -- -- tl -- -- -- dw --
02 -- -- dw -- -- -- dl -- dl -- -- -- dw -- --
03 dl -- -- dw -- -- -- dl -- -- -- dw -- -- dl
04 -- -- -- -- dw -- -- -- -- -- dw -- -- -- --
05 -- tl -- -- -- tl -- -- -- tl -- -- -- tl --
06 -- -- dl -- -- -- dl -- dl -- -- -- dl -- --
07 tw -- -- dl -- -- -- M+ A+ N+ I+ C+ -- -- tw
08 -- -- dl -- -- -- dl -- dl -- -- -- dl -- --
09 -- tl -- -- -- tl -- -- -- tl -- -- -- tl --
10 -- -- -- -- dw -- -- -- -- -- dw -- -- -- --
11 dl -- -- dw -- -- -- dl -- -- -- dw -- -- dl
12 -- -- dw -- -- -- dl -- dl -- -- -- dw -- --
13 -- dw -- -- -- tl -- -- -- tl -- -- -- dw --
14 tw -- -- dl -- -- -- tw -- -- -- dl -- -- tw

Rack: O U
> submit
MANIC accepted
Play is worth 24 points.
00 01 02 03 04 05 06 07 08 09 10 11 12 13 14
00 tw -- -- dl -- -- -- tw -- -- -- dl -- -- tw
01 -- dw -- -- -- tl -- -- -- tl -- -- -- dw --
02 -- -- dw -- -- -- dl -- dl -- -- -- dw -- --
03 dl -- -- dw -- -- -- dl -- -- -- dw -- -- dl
04 -- -- -- -- dw -- -- -- -- -- dw -- -- -- --
05 -- tl -- -- -- tl -- -- -- tl -- -- -- tl --
06 -- -- dl -- -- -- dl -- dl -- -- -- dl -- --
07 tw -- -- dl -- -- -- M  A  N  I  C  -- -- tw
08 -- -- dl -- -- -- dl -- dl -- -- -- dl -- --
09 -- tl -- -- -- tl -- -- -- tl -- -- -- tl --
10 -- -- -- -- dw -- -- -- -- -- dw -- -- -- --
11 dl -- -- dw -- -- -- dl -- -- -- dw -- -- dl
12 -- -- dw -- -- -- dl -- dl -- -- -- dw -- --
13 -- dw -- -- -- tl -- -- -- tl -- -- -- dw --
14 tw -- -- dl -- -- -- tw -- -- -- dl -- -- tw

Rack: A B J N O T U
There are 86 tiles in the bag.
Score: 24
00 01 02 03 04 05 06 07 08 09 10 11 12 13 14
00 tw -- -- dl -- -- -- tw -- -- -- dl -- -- tw
01 -- dw -- -- -- tl -- -- -- tl -- -- -- dw --
02 -- -- dw -- -- -- dl -- dl -- -- -- dw -- --
03 dl -- -- dw -- -- -- dl -- -- -- dw -- -- dl
04 -- -- -- -- dw -- -- -- -- -- dw -- -- -- --
05 -- tl -- -- -- tl -- -- -- tl -- -- -- tl --
06 -- -- dl -- -- -- dl -- dl -- -- -- dl -- --
07 tw -- -- dl -- -- -- M  A  N  I  C  -- -- tw
08 -- -- dl -- -- -- dl -- dl -- -- -- dl -- --
09 -- tl -- -- -- tl -- -- -- tl -- -- -- tl --
10 -- -- -- -- dw -- -- -- -- -- dw -- -- -- --
11 dl -- -- dw -- -- -- dl -- -- -- dw -- -- dl
12 -- -- dw -- -- -- dl -- dl -- -- -- dw -- --
13 -- dw -- -- -- tl -- -- -- tl -- -- -- dw --
14 tw -- -- dl -- -- -- tw -- -- -- dl -- -- tw

Rack: A B J N O T U
> wa banjo 8 3
00 01 02 03 04 05 06 07 08 09 10 11 12 13 14
00 tw -- -- dl -- -- -- tw -- -- -- dl -- -- tw
01 -- dw -- -- -- tl -- -- -- tl -- -- -- dw --
02 -- -- dw -- -- -- dl -- dl -- -- -- dw -- --
03 dl -- -- dw -- -- -- dl -- -- -- dw -- -- dl
04 -- -- -- -- dw -- -- -- -- -- dw -- -- -- --
05 -- tl -- -- -- tl -- -- -- tl -- -- -- tl --
06 -- -- dl -- -- -- dl -- dl -- -- -- dl -- --
07 tw -- -- dl -- -- -- M  A  N  I  C  -- -- tw
08 -- -- dl B+ A+ N+ J+ O+ dl -- -- -- dl -- --
09 -- tl -- -- -- tl -- -- -- tl -- -- -- tl --
10 -- -- -- -- dw -- -- -- -- -- dw -- -- -- --
11 dl -- -- dw -- -- -- dl -- -- -- dw -- -- dl
12 -- -- dw -- -- -- dl -- dl -- -- -- dw -- --
13 -- dw -- -- -- tl -- -- -- tl -- -- -- dw --
14 tw -- -- dl -- -- -- tw -- -- -- dl -- -- tw

Rack: T U
> submit
BANJO accepted
MO accepted
Play is worth 26 points.
00 01 02 03 04 05 06 07 08 09 10 11 12 13 14
00 tw -- -- dl -- -- -- tw -- -- -- dl -- -- tw
01 -- dw -- -- -- tl -- -- -- tl -- -- -- dw --
02 -- -- dw -- -- -- dl -- dl -- -- -- dw -- --
03 dl -- -- dw -- -- -- dl -- -- -- dw -- -- dl
04 -- -- -- -- dw -- -- -- -- -- dw -- -- -- --
05 -- tl -- -- -- tl -- -- -- tl -- -- -- tl --
06 -- -- dl -- -- -- dl -- dl -- -- -- dl -- --
07 tw -- -- dl -- -- -- M  A  N  I  C  -- -- tw
08 -- -- dl B  A  N  J  O  dl -- -- -- dl -- --
09 -- tl -- -- -- tl -- -- -- tl -- -- -- tl --
10 -- -- -- -- dw -- -- -- -- -- dw -- -- -- --
11 dl -- -- dw -- -- -- dl -- -- -- dw -- -- dl
12 -- -- dw -- -- -- dl -- dl -- -- -- dw -- --
13 -- dw -- -- -- tl -- -- -- tl -- -- -- dw --
14 tw -- -- dl -- -- -- tw -- -- -- dl -- -- tw

Rack: E O O R T T U
There are 81 tiles in the bag.
Score: 50

Pretty cool! It recognizes the crossings and scores accordingly.

Making a Bot #

After some boring but requisite testing, we can move on to making a bot. For this section we’ll make a greedy optimizer. It will always play the highest scoring move available for the current turn.

How to do this? The naive approach is an exhaustive search. The less-naive approach will be derived from the naive one so let’s start there. Consider the board from above:

00 01 02 03 04 05 06 07 08 09 10 11 12 13 14
00 tw -- -- dl -- -- -- tw -- -- -- dl -- -- tw
01 -- dw -- -- -- tl -- -- -- tl -- -- -- dw --
02 -- -- dw -- -- -- dl -- dl -- -- -- dw -- --
03 dl -- -- dw -- -- -- dl -- -- -- dw -- -- dl
04 -- -- -- -- dw -- -- -- -- -- dw -- -- -- --
05 -- tl -- -- -- tl -- -- -- tl -- -- -- tl --
06 -- -- dl -- -- -- dl -- dl -- -- -- dl -- --
07 tw -- -- dl -- -- -- M  A  N  I  C  -- -- tw
08 -- -- dl B  A  N  J  O  dl -- -- -- dl -- --
09 -- tl -- -- -- tl -- -- -- tl -- -- -- tl --
10 -- -- -- -- dw -- -- -- -- -- dw -- -- -- --
11 dl -- -- dw -- -- -- dl -- -- -- dw -- -- dl
12 -- -- dw -- -- -- dl -- dl -- -- -- dw -- --
13 -- dw -- -- -- tl -- -- -- tl -- -- -- dw --
14 tw -- -- dl -- -- -- tw -- -- -- dl -- -- tw

One thing that’s certain about the next play is that at least one of its tiles will be located at one of the above’s neighboring spaces, indicated with pluses (++) below.

00 01 02 03 04 05 06 07 08 09 10 11 12 13 14
00 tw -- -- dl -- -- -- tw -- -- -- dl -- -- tw
01 -- dw -- -- -- tl -- -- -- tl -- -- -- dw --
02 -- -- dw -- -- -- dl -- dl -- -- -- dw -- --
03 dl -- -- dw -- -- -- dl -- -- -- dw -- -- dl
04 -- -- -- -- dw -- -- -- -- -- dw -- -- -- --
05 -- tl -- -- -- tl -- -- -- tl -- -- -- tl --
06 -- -- dl -- -- -- dl ++ ++ ++ ++ ++ dl -- --
07 tw -- -- ++ ++ ++ ++ M  A  N  I  C  ++ -- tw
08 -- -- ++ B  A  N  J  O  ++ ++ ++ ++ dl -- --
09 -- tl -- ++ ++ ++ ++ ++ -- tl -- -- -- tl --
10 -- -- -- -- dw -- -- -- -- -- dw -- -- -- --
11 dl -- -- dw -- -- -- dl -- -- -- dw -- -- dl
12 -- -- dw -- -- -- dl -- dl -- -- -- dw -- --
13 -- dw -- -- -- tl -- -- -- tl -- -- -- dw --
14 tw -- -- dl -- -- -- tw -- -- -- dl -- -- tw

For each of these neighboring spaces, we can pick a tile from our rack and a direction (down or across). We check if the result is a word, and if so, save it. At the end, we pick the highest scoring of those moves.

We can split this behavior up into a few functions as shown below. The Rust code gets a little too in the weeds, so we’ll write these in pseudocode instead:

function greediest_word(board, rack):
    best_play = NULL
    foreach neighbor in board.neighbors:
        best_option = find_words(board, rack, neighbor)
        best_play = max(best_option, best_play)


function find_words(board, rack, starter_cell):
    best_across = find_words_across(board, rack, starter_cell, NULL)
    best_down = find_words_down(board, rack, starter_cell, NULL)

    best_option = max(best_across, best_down)


function find_words_across(board, rack, starter_cell, best):
    # check if the cross is a legal word
    if not is_word_down(board, starter_cell):
        return best

    # score words as we go
    if is_word(board, starter_cell):
        best = max(best, score_word_across(board, starter_cell))

    if rack is empty:
        return best

    # get the leftmost and rightmost free cells from the starter_cell
    across_borders = get_across_borders(board, starter_cell)

    for each candidate in across_borders:
        for each letter in rack:
            put_tile(board, rack, letter, candidate)
            best = max(best, find_words_across(board, rack, candidate, best))
            return_to_rack(board, rack, letter, candidate)
    
    return best

The logic will be similar for find_words_down().

In principle, this will find the best word to play, but you will be waiting a long time to find it. The algorithm wastes a ton of time checking for words that don’t exist. Instead of doing an exhaustive search, we need a branch-and-bound algorithm to limit possibilities. All we need to do is come up with a promising() function to determine whether a path is worth going down.

function find_words_across(board, rack, starter_cell, best):
    # check if the cross is a legal word
    if not is_word_down(board, starter_cell):
        return best

    ##################################
    # REJECT NON-PROMISING SOLUTIONS #
    ##################################
    substr = get_candidate_word(board, starter_cell)
    if not promising(substr):
        return best

    # score words as we go
    if is_word(board, starter_cell):
        best = max(best, score_word_across(board, starter_cell))

    if rack is empty:
        return best

    # get the leftmost and rightmost free cells from the starter_cell
    across_borders = get_across_borders(board, starter_cell)

    for each candidate in across_borders:
        for each letter in rack:
            put_tile(board, rack, letter, candidate)
            best = max(best, find_words_across(board, rack, candidate, best))
            return_to_rack(board, rack, letter, candidate)
    
    return best

We know that if a candidate contains a substring that doesn’t exist in any word in the dictionary, we can reject that path. Let’s write a promising function starting there.

function promising(substr):
    for each word in dictionary:
        if word contains substr:
            return true
    return false

This promising function will probably speed up the algorithm a bit, but it’s still very slow. Each time a substring is not promising, we iterate through the entire dictionary. We need a faster way to determine whether we should continue down a substring’s path. To do that, we’ll need to make another dictionary.

Consider the word ORNITHOLOGY. Let’s construct all its endings like so:

ORNITHOLOGY
RNITHOLOGY
NITHOLOGY
ITHOLOGY
THOLOGY
HOLOGY
OLOGY
LOGY
OGY
GY
Y

What if we did this to every word and placed the results in dictionary order? Here’s a snip of what that would look like:

...
FICEHOLDER
FICEHOLDERS
FICENCE
FICENCES
FICENT
FICENTLY
FICER
FICERED
FICERING
FICERS
FICES
FICHE
FICHES
FICHU
FICHUS
FICI
FICIAL
...

Now if we did a binary search of some substring on this list, what would happen? Well, if it’s a substring of some word in the original dictionary, it must be the start of some word in this dictionary! Therefore a binary search on this dictionary can tell us whether a substring is promising!

Let’s rewrite the promising function.

function promising(substr):
    index = endings_dictionary.binary_search(substr)
    if endings_dictionary[index] starts with substr:
        return true
    return false

Binary search is so much better than a linear search. Instead of searching through 200,000 dictionary entries for each call to promising(), we now only search around 20. Turns out this is enough to reduce the bot’s runtime from hours to a fraction of a second.

Game Example #

Now let’s initialize two of these bots and see the highlights of a game.

New scrabble game!
   00 01 02 03 04 05 06 07 08 09 10 11 12 13 14
00 tw -- -- dl -- -- -- tw -- -- -- dl -- -- tw
01 -- dw -- -- -- tl -- -- -- tl -- -- -- dw --
02 -- -- dw -- -- -- dl -- dl -- -- -- dw -- --
03 dl -- -- dw -- -- -- dl -- -- -- dw -- -- dl
04 -- -- -- -- dw -- -- -- -- -- dw -- -- -- --
05 -- tl -- -- -- tl -- -- -- tl -- -- -- tl --
06 -- -- dl -- -- -- dl -- dl -- -- -- dl -- --
07 tw -- -- dl -- -- -- dw -- -- -- dl -- -- tw
08 -- -- dl -- -- -- dl -- dl -- -- -- dl -- --
09 -- tl -- -- -- tl -- -- -- tl -- -- -- tl --
10 -- -- -- -- dw -- -- -- -- -- dw -- -- -- --
11 dl -- -- dw -- -- -- dl -- -- -- dw -- -- dl
12 -- -- dw -- -- -- dl -- dl -- -- -- dw -- --
13 -- dw -- -- -- tl -- -- -- tl -- -- -- dw --
14 tw -- -- dl -- -- -- tw -- -- -- dl -- -- tw

Rack: A A I J K O O
Rack: F G I I L L V
Player 1 Score: 0
Player 2 Score: 0
   00 01 02 03 04 05 06 07 08 09 10 11 12 13 14
00 tw -- -- dl -- -- -- tw -- -- -- dl -- -- tw
01 -- dw -- -- -- tl -- -- -- tl -- -- -- dw --
02 -- -- dw -- -- -- dl -- dl -- -- -- dw -- --
03 dl -- -- dw -- -- -- dl -- -- -- dw -- -- dl
04 -- -- -- -- dw -- -- K+ -- -- dw -- -- -- --
05 -- tl -- -- -- tl -- O+ -- tl -- -- -- tl --
06 -- -- dl -- -- -- dl J+ dl -- -- -- dl -- --
07 tw -- -- dl -- -- -- I+ -- -- -- dl -- -- tw
08 -- -- dl -- -- -- dl -- dl -- -- -- dl -- --
09 -- tl -- -- -- tl -- -- -- tl -- -- -- tl --
10 -- -- -- -- dw -- -- -- -- -- dw -- -- -- --
11 dl -- -- dw -- -- -- dl -- -- -- dw -- -- dl
12 -- -- dw -- -- -- dl -- dl -- -- -- dw -- --
13 -- dw -- -- -- tl -- -- -- tl -- -- -- dw --
14 tw -- -- dl -- -- -- tw -- -- -- dl -- -- tw

KOJI accepted
Play is worth 30 points.
   00 01 02 03 04 05 06 07 08 09 10 11 12 13 14
00 tw -- -- dl -- -- -- tw -- -- -- dl -- -- tw
01 -- dw -- -- -- tl -- -- -- tl -- -- -- dw --
02 -- -- dw -- -- -- dl -- dl -- -- -- dw -- --
03 dl -- -- dw -- -- -- dl -- -- -- dw -- -- dl
04 -- -- -- -- F+ I+ L+ K  -- -- dw -- -- -- --
05 -- tl -- -- -- tl -- O  -- tl -- -- -- tl --
06 -- -- dl -- -- -- dl J  dl -- -- -- dl -- --
07 tw -- -- dl -- -- -- I  -- -- -- dl -- -- tw
08 -- -- dl -- -- -- dl -- dl -- -- -- dl -- --
09 -- tl -- -- -- tl -- -- -- tl -- -- -- tl --
10 -- -- -- -- dw -- -- -- -- -- dw -- -- -- --
11 dl -- -- dw -- -- -- dl -- -- -- dw -- -- dl
12 -- -- dw -- -- -- dl -- dl -- -- -- dw -- --
13 -- dw -- -- -- tl -- -- -- tl -- -- -- dw --
14 tw -- -- dl -- -- -- tw -- -- -- dl -- -- tw

FILK accepted
Play is worth 22 points.
   00 01 02 03 04 05 06 07 08 09 10 11 12 13 14
00 tw -- -- dl -- -- -- tw -- -- -- dl -- -- tw
01 -- dw -- -- -- tl -- -- -- tl -- -- -- dw --
02 -- -- dw -- -- -- dl -- dl -- -- -- dw -- --
03 dl -- -- dw -- -- -- dl -- -- -- dw -- -- dl
04 -- -- -- -- F  I  L  K  -- -- dw -- -- -- --
05 -- tl -- -- -- tl -- O  -- tl -- -- -- tl --
06 -- -- dl -- -- -- dl J  dl -- -- -- dl -- --
07 tw -- -- dl -- -- -- I  -- -- -- dl -- -- tw
08 -- -- dl -- -- -- dl -- dl -- -- -- dl -- --
09 -- tl -- -- -- tl -- -- -- tl -- -- -- tl --
10 -- -- -- -- dw -- -- -- -- -- dw -- -- -- --
11 dl -- -- dw -- -- -- dl -- -- -- dw -- -- dl
12 -- -- dw -- -- -- dl -- dl -- -- -- dw -- --
13 -- dw -- -- -- tl -- -- -- tl -- -- -- dw --
14 tw -- -- dl -- -- -- tw -- -- -- dl -- -- tw

...

   00 01 02 03 04 05 06 07 08 09 10 11 12 13 14
00 tw -- -- dl -- -- -- tw -- -- -- dl -- -- tw
01 -- dw -- -- -- tl -- -- -- tl -- -- -- dw --
02 -- -- dw -- -- -- dl -- dl -- -- -- dw -- --
03 dl -- -- dw -- -- -- dl -- -- -- dw -- -- dl
04 -- -- -- -- F  I  L  K  -- -- dw -- -- -- --
05 -- tl -- -- A  tl -- O  -- tl -- -- -- tl --
06 -- -- dl -- C  -- dl J  dl -- -- -- dl -- --
07 tw -- -- dl U  -- -- I  -- -- -- dl -- -- tw
08 -- -- dl -- L  -- dl -- dl -- -- -- dl -- --
09 -- L+ I+ G+ A  T+ I+ V+ E+ tl -- -- -- tl --
10 -- -- -- -- E  -- -- -- -- -- dw -- -- -- --
11 dl -- -- dw -- -- -- dl -- -- -- dw -- -- dl
12 -- -- dw -- -- -- dl -- dl -- -- -- dw -- --
13 -- dw -- -- -- tl -- -- -- tl -- -- -- dw --
14 tw -- -- dl -- -- -- tw -- -- -- dl -- -- tw

LIGATIVE accepted
Play is worth 66 points.
   00 01 02 03 04 05 06 07 08 09 10 11 12 13 14
00 tw -- -- dl -- -- -- tw -- -- -- dl -- -- tw
01 -- dw -- -- -- tl -- -- -- tl -- -- -- dw --
02 -- -- dw -- -- -- dl -- dl -- -- -- dw -- --
03 dl -- -- dw -- -- -- dl -- -- -- dw -- -- dl
04 -- -- -- -- F  I  L  K  -- -- dw -- -- -- --
05 -- tl -- -- A  tl -- O  -- tl -- -- -- tl --
06 -- -- dl -- C  -- dl J  dl -- -- -- dl -- --
07 tw -- -- dl U  -- -- I  -- -- -- dl -- -- tw
08 -- -- dl -- L  -- dl -- dl -- -- -- dl -- --
09 -- L  I  G  A  T  I  V  E  tl -- -- -- tl --
10 -- -- -- -- E  -- -- -- -- -- dw -- -- -- --
11 dl -- -- dw -- -- -- dl -- -- -- dw -- -- dl
12 -- -- dw -- -- -- dl -- dl -- -- -- dw -- --
13 -- dw -- -- -- tl -- -- -- tl -- -- -- dw --
14 tw -- -- dl -- -- -- tw -- -- -- dl -- -- tw

...


   00 01 02 03 04 05 06 07 08 09 10 11 12 13 14
00 tw -- -- dl -- -- -- tw -- Q+ -- dl -- -- tw
01 -- dw -- -- -- tl -- -- -- U+ -- -- -- dw --
02 -- -- dw -- -- -- dl -- dl I+ -- -- dw -- --
03 dl -- -- dw -- -- -- dl -- N+ -- dw -- -- dl
04 -- -- -- -- F  I  L  K  -- T+ dw -- -- -- --
05 -- tl -- -- A  tl -- O  W  E+ -- -- -- tl --
06 -- -- dl -- C  -- dl J  O  T+ -- -- dl -- --
07 tw -- -- dl U  -- -- I  D  -- -- dl -- -- tw
08 -- -- dl -- L  -- dl -- G  -- -- -- dl -- --
09 -- L  I  G  A  T  I  V  E  tl -- -- -- B  --
10 -- -- -- -- E  -- -- -- S  T  A  T  O  R  --
11 dl -- -- dw -- -- -- dl -- -- -- dw -- A  dl
12 -- -- dw -- -- -- dl -- dl -- -- -- dw I  --
13 -- dw -- -- -- tl -- -- -- tl -- -- -- N  --
14 tw -- -- dl -- -- -- tw -- -- -- dl -- Y  tw

QUINTET accepted
OWE accepted
JOT accepted
Play is worth 88 points.
   00 01 02 03 04 05 06 07 08 09 10 11 12 13 14
00 tw -- -- dl -- -- -- tw -- Q  -- dl -- -- tw
01 -- dw -- -- -- tl -- -- -- U  -- -- -- dw --
02 -- -- dw -- -- -- dl -- dl I  -- -- dw -- --
03 dl -- -- dw -- -- -- dl -- N  -- dw -- -- dl
04 -- -- -- -- F  I  L  K  -- T  dw -- -- -- --
05 -- tl -- -- A  tl -- O  W  E  -- -- -- tl --
06 -- -- dl -- C  -- dl J  O  T  -- -- dl -- --
07 tw -- -- dl U  -- -- I  D  -- -- dl -- -- tw
08 -- -- dl -- L  -- dl -- G  -- -- -- dl -- --
09 -- L  I  G  A  T  I  V  E  tl -- -- -- B  --
10 -- -- -- -- E  -- -- -- S  T  A  T  O  R  --
11 dl -- -- dw -- -- -- dl -- -- -- dw -- A  dl
12 -- -- dw -- -- -- dl -- dl -- -- -- dw I  --
13 -- dw -- -- -- tl -- -- -- tl -- -- -- N  --
14 tw -- -- dl -- -- -- tw -- -- -- dl -- Y  tw

Rack: E E I P T U V
Rack: C E E P R S Y
Player 1 Score: 119
Player 2 Score: 199
   00 01 02 03 04 05 06 07 08 09 10 11 12 13 14
00 tw -- -- dl -- -- -- P+ I+ Q  U+ E+ T+ -- tw
01 -- dw -- -- -- tl -- -- -- U  -- -- -- dw --
02 -- -- dw -- -- -- dl -- dl I  -- -- dw -- --
03 dl -- -- dw -- -- -- dl -- N  -- dw -- -- dl
04 -- -- -- -- F  I  L  K  -- T  dw -- -- -- --
05 -- tl -- -- A  tl -- O  W  E  -- -- -- tl --
06 -- -- dl -- C  -- dl J  O  T  -- -- dl -- --
07 tw -- -- dl U  -- -- I  D  -- -- dl -- -- tw
08 -- -- dl -- L  -- dl -- G  -- -- -- dl -- --
09 -- L  I  G  A  T  I  V  E  tl -- -- -- B  --
10 -- -- -- -- E  -- -- -- S  T  A  T  O  R  --
11 dl -- -- dw -- -- -- dl -- -- -- dw -- A  dl
12 -- -- dw -- -- -- dl -- dl -- -- -- dw I  --
13 -- dw -- -- -- tl -- -- -- tl -- -- -- N  --
14 tw -- -- dl -- -- -- tw -- -- -- dl -- Y  tw

PIQUET accepted
Play is worth 54 points.
   00 01 02 03 04 05 06 07 08 09 10 11 12 13 14
00 tw -- -- dl -- -- -- P  I  Q  U  E  T  -- tw
01 -- dw -- -- -- tl -- -- -- U  -- -- -- dw --
02 -- -- dw -- -- -- dl -- dl I  -- -- dw -- --
03 dl -- -- dw -- -- -- dl -- N  -- dw -- -- dl
04 -- -- -- -- F  I  L  K  -- T  dw -- -- -- --
05 -- tl -- -- A  tl -- O  W  E  -- -- -- tl --
06 -- -- dl -- C  -- dl J  O  T  -- -- dl -- --
07 tw -- -- dl U  -- -- I  D  -- -- dl -- -- P+
08 -- -- dl -- L  -- dl -- G  -- -- -- dl -- E+
09 -- L  I  G  A  T  I  V  E  tl -- -- -- B  Y+
10 -- -- -- -- E  -- -- -- S  T  A  T  O  R  S+
11 dl -- -- dw -- -- -- dl -- -- -- dw -- A  E+
12 -- -- dw -- -- -- dl -- dl -- -- -- dw I  --
13 -- dw -- -- -- tl -- -- -- tl -- -- -- N  --
14 tw -- -- dl -- -- -- tw -- -- -- dl -- Y  tw

PEYSE accepted
BY accepted
STATORS accepted
AE accepted
Play is worth 50 points.
   00 01 02 03 04 05 06 07 08 09 10 11 12 13 14
00 tw -- -- dl -- -- -- P  I  Q  U  E  T  -- tw
01 -- dw -- -- -- tl -- -- -- U  -- -- -- dw --
02 -- -- dw -- -- -- dl -- dl I  -- -- dw -- --
03 dl -- -- dw -- -- -- dl -- N  -- dw -- -- dl
04 -- -- -- -- F  I  L  K  -- T  dw -- -- -- --
05 -- tl -- -- A  tl -- O  W  E  -- -- -- tl --
06 -- -- dl -- C  -- dl J  O  T  -- -- dl -- --
07 tw -- -- dl U  -- -- I  D  -- -- dl -- -- P
08 -- -- dl -- L  -- dl -- G  -- -- -- dl -- E
09 -- L  I  G  A  T  I  V  E  tl -- -- -- B  Y
10 -- -- -- -- E  -- -- -- S  T  A  T  O  R  S
11 dl -- -- dw -- -- -- dl -- -- -- dw -- A  E
12 -- -- dw -- -- -- dl -- dl -- -- -- dw I  --
13 -- dw -- -- -- tl -- -- -- tl -- -- -- N  --
14 tw -- -- dl -- -- -- tw -- -- -- dl -- Y  tw

...


   00 01 02 03 04 05 06 07 08 09 10 11 12 13 14
00 tw -- -- dl -- -- -- P  I  Q  U  E  T  -- tw
01 -- dw -- -- -- tl -- -- -- U  -- -- -- dw --
02 -- -- dw -- -- -- dl -- dl I  F  -- dw -- --
03 dl -- -- dw -- -- -- dl -- N  E  dw -- -- dl
04 -- -- -- -- F  I  L  K  -- T  A  -- -- -- --
05 -- tl -- -- A  tl -- O  W  E  R  -- -- tl --
06 -- -- dl -- C  -- dl J  O  T  -- -- dl -- --
07 tw -- -- dl U  -- -- I  D  -- -- dl -- -- P
08 -- -- dl -- L  -- dl -- G  -- -- -- dl -- E
09 -- L  I  G  A  T  I  V  E  tl -- -- -- B  Y
10 -- -- -- -- E  -- -- -- S  T  A  T  O  R  S
11 dl -- -- dw -- -- -- dl -- -- -- dw -- A  E
12 -- -- dw -- -- -- dl -- dl -- -- -- dw I  --
13 -- dw -- -- -- tl -- -- -- tl -- -- -- N  --
14 tw -- -- dl -- -- -- tw -- -- E  N  Z  Y  M

Rack: E E E N N O V
Rack: A C H N O R S
Player 1 Score: 205
Player 2 Score: 309
    00 01 02 03 04 05 06 07 08 09 10 11 12 13 14
00 tw -- -- dl -- -- -- P  I  Q  U  E  T  -- tw
01 -- dw -- -- -- V+ -- -- -- U  -- -- -- dw --
02 -- -- dw -- -- E+ dl -- dl I  F  -- dw -- --
03 dl -- -- dw -- N+ -- dl -- N  E  dw -- -- dl
04 -- -- -- -- F  I  L  K  -- T  A  -- -- -- --
05 -- tl -- -- A  N+ -- O  W  E  R  -- -- tl --
06 -- -- dl -- C  -- dl J  O  T  -- -- dl -- --
07 tw -- -- dl U  -- -- I  D  -- -- dl -- -- P
08 -- -- dl -- L  -- dl -- G  -- -- -- dl -- E
09 -- L  I  G  A  T  I  V  E  tl -- -- -- B  Y
10 -- -- -- -- E  -- -- -- S  T  A  T  O  R  S
11 dl -- -- dw -- -- -- dl -- -- -- dw -- A  E
12 -- -- dw -- -- -- dl -- dl -- -- -- dw I  --
13 -- dw -- -- -- tl -- -- -- tl -- -- -- N  --
14 tw -- -- dl -- -- -- tw -- -- E  N  Z  Y  M

VENIN accepted
AN accepted
Play is worth 22 points.
    00 01 02 03 04 05 06 07 08 09 10 11 12 13 14
00 tw -- -- dl -- -- -- P  I  Q  U  E  T  S+ tw
01 -- dw -- -- -- V  -- -- -- U  -- -- -- H+ --
02 -- -- dw -- -- E  dl -- dl I  F  -- dw O+ --
03 dl -- -- dw -- N  -- dl -- N  E  dw -- R+ dl
04 -- -- -- -- F  I  L  K  -- T  A  -- -- A+ --
05 -- tl -- -- A  N  -- O  W  E  R  -- -- N+ --
06 -- -- dl -- C  -- dl J  O  T  -- -- dl -- --
07 tw -- -- dl U  -- -- I  D  -- -- dl -- -- P
08 -- -- dl -- L  -- dl -- G  -- -- -- dl -- E
09 -- L  I  G  A  T  I  V  E  tl -- -- -- B  Y
10 -- -- -- -- E  -- -- -- S  T  A  T  O  R  S
11 dl -- -- dw -- -- -- dl -- -- -- dw -- A  E
12 -- -- dw -- -- -- dl -- dl -- -- -- dw I  --
13 -- dw -- -- -- tl -- -- -- tl -- -- -- N  --
14 tw -- -- dl -- -- -- tw -- -- E  N  Z  Y  M

SHORAN accepted
PIQUETS accepted
Play is worth 40 points.
    00 01 02 03 04 05 06 07 08 09 10 11 12 13 14
00 tw -- -- dl -- -- -- P  I  Q  U  E  T  S  tw
01 -- dw -- -- -- V  -- -- -- U  -- -- -- H  --
02 -- -- dw -- -- E  dl -- dl I  F  -- dw O  --
03 dl -- -- dw -- N  -- dl -- N  E  dw -- R  dl
04 -- -- -- -- F  I  L  K  -- T  A  -- -- A  --
05 -- tl -- -- A  N  -- O  W  E  R  -- -- N  --
06 -- -- dl -- C  -- dl J  O  T  -- -- dl -- --
07 tw -- -- dl U  -- -- I  D  -- -- dl -- -- P
08 -- -- dl -- L  -- dl -- G  -- -- -- dl -- E
09 -- L  I  G  A  T  I  V  E  tl -- -- -- B  Y
10 -- -- -- -- E  -- -- -- S  T  A  T  O  R  S
11 dl -- -- dw -- -- -- dl -- -- -- dw -- A  E
12 -- -- dw -- -- -- dl -- dl -- -- -- dw I  --
13 -- dw -- -- -- tl -- -- -- tl -- -- -- N  --
14 tw -- -- dl -- -- -- tw -- -- E  N  Z  Y  M

...

Rack: A G I O O R R
Rack: A D I O R U X
Player 1 Score: 299
Player 2 Score: 414
    00 01 02 03 04 05 06 07 08 09 10 11 12 13 14
00 tw -- -- dl -- -- -- P  I  Q  U  E  T  S  tw
01 -- dw -- -- -- V  -- -- -- U  -- -- E  H  --
02 -- -- dw -- -- E  dl -- dl I  F  -- W  O  O+
03 dl -- -- dw -- N  -- dl -- N  E  dw E  R  R+
04 -- -- -- -- F  I  L  K  -- T  A  -- D  A  G+
05 -- tl -- -- A  N  -- O  W  E  R  -- -- N  --
06 -- -- dl -- C  -- dl J  O  T  -- -- dl -- --
07 tw -- C  L  U  B  -- I  D  -- -- dl -- -- P
08 -- -- H  A  L  E  D  -- G  -- -- -- dl -- E
09 -- L  I  G  A  T  I  V  E  tl -- -- -- B  Y
10 -- -- -- -- E  -- -- -- S  T  A  T  O  R  S
11 dl -- -- dw -- -- -- dl -- -- -- dw -- A  E
12 -- -- dw -- -- -- dl -- dl -- -- -- M  I  --
13 -- dw -- -- -- tl -- -- -- tl -- -- O  N  --
14 tw -- -- dl -- -- -- tw -- -- E  N  Z  Y  M

ORG accepted
WOO accepted
ERR accepted
DAG accepted
Play is worth 20 points.
    00 01 02 03 04 05 06 07 08 09 10 11 12 13 14
00 tw -- -- dl -- -- -- P  I  Q  U  E  T  S  tw
01 -- dw -- -- -- V  -- -- -- U  -- -- E  H  --
02 -- -- dw -- -- E  dl -- dl I  F  -- W  O  O
03 dl -- -- dw -- N  -- dl -- N  E  dw E  R  R
04 -- -- -- -- F  I  L  K  -- T  A  -- D  A  G
05 -- tl -- -- A  N  -- O  W  E  R  -- -- N  --
06 -- -- dl -- C  -- dl J  O  T  -- -- dl -- --
07 tw -- C  L  U  B  -- I  D  -- -- dl -- -- P
08 -- -- H  A  L  E  D  -- G  -- -- -- dl -- E
09 -- L  I  G  A  T  I  V  E  tl -- -- -- B  Y
10 -- -- -- -- E  -- -- -- S  T  A  T  O  R  S
11 dl -- -- dw -- -- -- dl -- -- X+ I+ -- A  E
12 -- -- dw -- -- -- dl -- dl -- -- -- M  I  --
13 -- dw -- -- -- tl -- -- -- tl -- -- O  N  --
14 tw -- -- dl -- -- -- tw -- -- E  N  Z  Y  M

XI accepted
AX accepted
TI accepted
Play is worth 31 points.
    00 01 02 03 04 05 06 07 08 09 10 11 12 13 14
00 tw -- -- dl -- -- -- P  I  Q  U  E  T  S  tw
01 -- dw -- -- -- V  -- -- -- U  -- -- E  H  --
02 -- -- dw -- -- E  dl -- dl I  F  -- W  O  O
03 dl -- -- dw -- N  -- dl -- N  E  dw E  R  R
04 -- -- -- -- F  I  L  K  -- T  A  -- D  A  G
05 -- tl -- -- A  N  -- O  W  E  R  -- -- N  --
06 -- -- dl -- C  -- dl J  O  T  -- -- dl -- --
07 tw -- C  L  U  B  -- I  D  -- -- dl -- -- P
08 -- -- H  A  L  E  D  -- G  -- -- -- dl -- E
09 -- L  I  G  A  T  I  V  E  tl -- -- -- B  Y
10 -- -- -- -- E  -- -- -- S  T  A  T  O  R  S
11 dl -- -- dw -- -- -- dl -- -- X  I  -- A  E
12 -- -- dw -- -- -- dl -- dl -- -- -- M  I  --
13 -- dw -- -- -- tl -- -- -- tl -- -- O  N  --
14 tw -- -- dl -- -- -- tw -- -- E  N  Z  Y  M

Rack: A I O R S
Rack: A D O R U
Player 1 Score: 319
Player 2 Score: 445
    00 01 02 03 04 05 06 07 08 09 10 11 12 13 14
00 tw -- -- dl -- -- -- P  I  Q  U  E  T  S  tw
01 -- dw -- -- -- V  -- -- -- U  -- -- E  H  --
02 -- -- dw -- -- E  dl -- dl I  F  -- W  O  O
03 dl -- -- dw -- N  -- dl -- N  E  dw E  R  R
04 -- -- -- -- F  I  L  K  -- T  A  -- D  A  G
05 -- tl -- -- A  N  -- O  W  E  R  -- -- N  --
06 -- -- dl -- C  -- dl J  O  T  -- -- dl -- --
07 tw -- C  L  U  B  -- I  D  -- -- dl -- -- P
08 -- -- H  A  L  E  D  -- G  -- -- -- dl -- E
09 -- L  I  G  A  T  I  V  E  tl -- -- -- B  Y
10 -- -- R+ -- E  -- -- -- S  T  A  T  O  R  S
11 dl -- O+ dw -- -- -- dl -- -- X  I  -- A  E
12 -- -- S+ -- -- -- dl -- dl -- -- -- M  I  --
13 -- dw -- -- -- tl -- -- -- tl -- -- O  N  --
14 tw -- -- dl -- -- -- tw -- -- E  N  Z  Y  M

CHIROS accepted
Play is worth 22 points.
Game finsihed!
Player 1 Score: 341
Player 2 Score: 445

The bot’s pretty good at Scrabble.