Tic-tac-toe was 9 cells, 3 marks, 8 winning lines. Scrabble is 225 cells, 100 tiles, 27 letter values, premium squares, a 270,000-word dictionary, and a turn that requires you to spell a real word that connects to the existing tiles. The complexity is an order of magnitude bigger and the data structures matter. This is the project where you start to feel like a programmer, not someone copying tutorials.

This week we build the static skeleton: the board, the bag, the rack, the score table, and a turn that places tiles and computes a score. Next week we tackle the dictionary — looking up words quickly enough that the program doesn't grind to a halt.

The plan

  1. The board. 15×15 grid of squares. Most squares are blank; some are double-letter, triple-letter, double-word, or triple-word.
  2. The bag. 100 letter tiles in known proportions (A×9, B×2, … Z×1, blank×2).
  3. The rack. Each player holds 7 tiles, refilled from the bag after every turn.
  4. The turn. Choose a starting square, a direction (across or down), and a sequence of tiles from your rack. Validate, score, commit.
  5. Scoring. Sum letter values, apply premium squares for newly placed tiles, multiply word totals, add a 50-point bonus for using all 7 tiles.

That last point — "newly placed tiles only get the premium" — is the rule that catches every first-time implementation. Premium squares are consumed once and only once.

Modelling the squares

Every cell on the board has two pieces of state: the tile placed there (if any), and the premium type baked into the square. The premium is part of the board, fixed at the start; the tile is mutable.

enum class Premium { None, DoubleLetter, TripleLetter, DoubleWord, TripleWord };

struct Tile {
    char letter;     // 'A'..'Z', or ' ' for a blank used as letter X
    char assigned;   // for blanks: which letter the player chose
};

struct Square {
    Premium           premium;
    std::optional<Tile> tile;
};

class Board {
public:
    Board();                                     // initialise premiums
    bool     empty_at(int r, int c) const;
    char     letter_at(int r, int c) const;  // '\0' if empty
    Premium  premium_at(int r, int c) const;
    void     commit(int r, int c, Tile t);
private:
    Square grid_[15][15];
};

std::optional<Tile> is the C++17 idiomatic way to say "this might or might not have a value" — exactly what we want for a square that may or may not have a tile. The constructor walks the grid once and sets each square's premium from a hardcoded layout (the standard Scrabble premium pattern is symmetric, so a single 8×8 quadrant repeated four times is enough).

The bag

The bag is 100 tiles in known proportions. The simplest representation is a std::vector<Tile> that you shuffle once at the start, then pop from the back as players draw.

class Bag {
public:
    Bag() {
        // distribution from the standard English Scrabble set
        struct Entry { char letter; int count; };
        Entry dist[] = {
            {'A',9},{'B',2},{'C',2},{'D',4},{'E',12},{'F',2},
            {'G',3},{'H',2},{'I',9},{'J',1},{'K',1},{'L',4},
            {'M',2},{'N',6},{'O',8},{'P',2},{'Q',1},{'R',6},
            {'S',4},{'T',6},{'U',4},{'V',2},{'W',2},{'X',1},
            {'Y',2},{'Z',1},{' ',2}     // blanks
        };
        for (auto& e : dist)
            for (int i = 0; i < e.count; i++)
                tiles_.push_back({e.letter, 0});

        std::random_device rd;
        std::shuffle(tiles_.begin(), tiles_.end(), std::mt19937(rd()));
    }

    bool           empty() const { return tiles_.empty(); }
    std::optional<Tile> draw() {
        if (empty()) return std::nullopt;
        Tile t = tiles_.back(); tiles_.pop_back(); return t;
    }

private:
    std::vector<Tile> tiles_;
};

std::shuffle with a Mersenne Twister gives you a properly random ordering. Drawing is just popping from the back; the bag knows when it's empty.

The rack and the score

The rack is just std::vector<Tile> with size ≤ 7. After every turn the player refills from the bag until either the rack is full or the bag is empty.

Letter values are a fixed table — A is 1, Z is 10, blanks are 0:

int letter_value(char c) {
    static const int v[26] = {
        1,3,3,2,1,4,2,4,1,8,5,1,3,   // A..M
        1,1,3,10,1,1,1,1,4,4,8,4,10          // N..Z
    };
    if (c == ' ') return 0;     // blank: zero points regardless of assigned letter
    return v[c - 'A'];
}

Scoring a turn

This is the part that matters. A move places one or more tiles in a single line (across or down). The score for the move is:

  1. Sum the value of every letter in the main word (the line you just played in), applying letter premiums for newly placed tiles only.
  2. Multiply the main-word total by the word premiums under newly placed tiles.
  3. For each cross-word the move forms (perpendicular words built off of an existing tile), score it the same way.
  4. Sum all word scores.
  5. If the player placed all 7 of their tiles in this turn, add 50 (the "bingo").

Below is a simplified score for one word direction; the full implementation calls this once for the main word and once per cross-word:

struct Placement { int r, c; char letter; bool is_new; };

int score_word(const Board& b, const std::vector<Placement>& word) {
    int letters = 0;
    int word_mult = 1;
    for (auto& p : word) {
        int v = letter_value(p.letter);
        if (p.is_new) {
            switch (b.premium_at(p.r, p.c)) {
                case Premium::DoubleLetter: v *= 2; break;
                case Premium::TripleLetter: v *= 3; break;
                case Premium::DoubleWord:   word_mult *= 2; break;
                case Premium::TripleWord:   word_mult *= 3; break;
                default: break;
            }
        }
        letters += v;
    }
    return letters * word_mult;
}

The is_new flag is the whole game. A premium square already covered by an old tile contributes nothing extra. A double-letter under a new tile doubles that tile only. Two triple-words spanned by one move multiply the word total by 9.

The validation problem

Before any of this scoring runs, you have to confirm the move is even legal:

The first two are spatial checks against the board. The third is the dictionary problem, and it's where Scrabble gets interesting computationally. A single 7-letter move can form up to eight different words simultaneously — main word plus seven cross-words — and you need to look each one up. Doing that with a slow lookup means a noticeable pause every turn. That's next week.

Why this matters

The skeleton you'll write this week uses everything from Phases 2–4: classes with invariants (Board, Bag), std::vector as workhorse, std::optional for "may or may not," random shuffling, score tables, and the multi-axis logic of placing pieces in 2D. There's no AI yet; the program is a referee that two humans take turns operating. Even at this stage it's substantially more code than tic-tac-toe — roughly 400 lines for a clean minimal version — and you'll feel why classes exist. Without them you'd drown in global arrays and helper functions named get_premium2.

Tic-tac-toe needed three concepts. Scrabble needs ten. Chess will need thirty. The art is keeping each one small.

Try it yourself

What's next

Right now, dictionary validation is missing — the player can spell QXVZJ and we'll happily score it. Next week we add a dictionary lookup that runs in microseconds, even with 270,000 words. The trick is the hash map from Week 31 — and, as a bonus, sorting the letters of a word turns "what words can I spell with these tiles?" into a lookup, not a search.

Week 40 is Scrabble Lookup — the dictionary problem.

Quick check

1. When does a Scrabble premium square (e.g. triple-word) actually apply?
  1. Once, only when a tile is newly placed on it — covered tiles do nothing more
  2. Every time a word crosses the square
  3. Only on the centre tile
Reveal Answer

Answer: A. The premium is consumed by the first tile to land on it. It's the rule beginners forget when scoring.

2. What earns the 50-point 'bingo' bonus?
  1. Spelling a 7-letter word using all seven rack tiles in a single move
  2. Getting a triple-word score
  3. Winning the game
Reveal Answer

Answer: A. Bingos make competitive Scrabble what it is. Without them, the game becomes much less swingy.

3. Why use std::optional<Tile> for a Square's tile?
  1. Optionals are smaller than pointers
  2. It explicitly says 'may or may not have a tile' — avoids sentinel-value bugs
  3. Optionals are required by the STL
Reveal Answer

Answer: B. Sentinel values (like 'tile = -1 means empty') are a classic source of bugs. optional makes the absence explicit.