Defining the Game Models

Let's begin by setting up a models/game.ts file to house our model definitions.

First, we define a GameInfo interface. This structure represents what would eventually be persisted on a remote server — hypothetically, at least. The interface captures the 5 middle words (each paired with its own hint), the 2 edge words located at the top and bottom, plus a common hint that applies to both edges.

/** What comes from the server */
export interface GameInfo {
  words: Array<{ correct: string; hint: string }>;
  edgeWords: [string, string];
  edgeHint: string;
};
Enter fullscreen mode Exit fullscreen mode

Next comes a closely related interface, labeled Game. It extends the concept of GameInfo by including additional string fields that track the user's current input for each word.

/** What we store in our component */
export interface Game {
  words: Array<{ correct: string; hint: string; current: string }>;
  edgeWords: [string, string];
  currentEdgeWords: [string, string];
  edgeHint: string;
};
Enter fullscreen mode Exit fullscreen mode

This design works well for our demo, though it's certainly not the only plausible way to structure the data.

Since we're skipping an actual backend, we'll ship some fake data from the same module.

export const mock: GameInfo = {
  words: [
    { correct: 'CARE', hint: 'Compassionate attention given to someone' },
    { correct: 'CORE', hint: 'Discarded part of an apple' },
    { correct: 'CORK', hint: 'Stopper in a champagne bottle' },
    { correct: 'FORK', hint: 'Eating utensil with tines' },
    { correct: 'FORT', hint: '____nite (popular online game)' }
  ],
  edgeWords: ['BARE', 'FOOT'],
  edgeHint: 'Compound word for "having no shoes or socks on"'
};
Enter fullscreen mode Exit fullscreen mode

Keep in mind that the hint displayed for both edge words is the same one.

Here's the full set of words the player needs to reconstruct, in their correct sequence:

  • BARE
  • CARE
  • CORE
  • CORK
  • FORK
  • FORT
  • FOOT

Building the Game Service

Almost all of the game's runtime data will live inside a service, so let's create a services/game.service.ts file.

This is precisely where a dedicated state management library, such as NgRx, might come into play. We'll opt for a lighter touch and rely on Angular Signals.

@Injectable({ providedIn: 'root' })
export class GameService {

}
Enter fullscreen mode Exit fullscreen mode

This service manages two distinct pieces of state: game and focused.

@Injectable({ providedIn: 'root' })
export class GameService {

  game = signal<Game | null>(null);
  focused = signal<number | null>(null);
}
Enter fullscreen mode Exit fullscreen mode

The game state holds the active Game object, while focused tracks the index of the currently selected word — ranging from 0 to 6, given our seven-word setup. This focused index comes in handy for determining which hint should be displayed to the player.

We then add a method that populates the game state. In a production environment, this would be an API call; for now, it draws from our mock data.

  init() {
    const emptyString = Array(mock.edgeWords[0].length).fill(' ').join('');
    this.game.set({
      ...mock,
      words: mock.words.map(word => ({ ...word, current: emptyString })).sort(() => Math.random() - 0.5),
      currentEdgeWords: [emptyString, emptyString]
    });
  }
Enter fullscreen mode Exit fullscreen mode

The initialization logic fills each word's current value with blank characters — so for a four-letter word, you'd get four empty slots.

Notice the Math.random() call in there? We're deliberately shuffling the word order, forcing the player to re-arrange them into the correct sequence.

Computing the Derived States

Every piece of information we need can be derived directly from the game and focused signals, so let's set up some derived signals using computed.

To make things easier, we'll start by computing the word length. Since all words are the same length, we can pull this from any word in the game.

  wordLength = computed(() => this.game()?.edgeWords[0].length || 0);
Enter fullscreen mode Exit fullscreen mode

It doesn't matter which word you pick — they're all the same length.

We also need to track the current game status. The game can be in one of five possible states:

  • idle (game data hasn't loaded yet)
  • error (the middle words aren't solved at all)
  • unsorted (middle words are solved but not yet sorted)
  • sorted (middle words are correctly sorted)
  • solved (the edge words have also been solved)

To determine this, we'll need a helper method called differByOne() that checks whether two strings differ by exactly one character.

  private differByOne(str1: string, str2: string) {
    if (str1.length !== str2.length) return false;

    let diffCount = 0;

    for (let i = 0; i < str1.length; i++) {
      if (str1[i] !== str2[i]) diffCount++;
      if (diffCount > 1) return false;
    }

    return diffCount === 1;
  }
Enter fullscreen mode Exit fullscreen mode

With that in place, we can now build the gameStatus derived signal.

  gameStatus = computed(() => {

  });
Enter fullscreen mode Exit fullscreen mode

First, check whether a game exists. If there's no game yet, return idle.

    const game = this.game();
    if (!game) return 'idle';
Enter fullscreen mode Exit fullscreen mode

Next, verify whether all the middle words have been solved. If not, return error.

    const isHalfSolved = game.words.every(word => word.correct === word.current);

    if (!isHalfSolved) return 'error';
Enter fullscreen mode Exit fullscreen mode

Then, check whether the middle words are arranged in the correct order. If they aren't, return unsorted.

    const isSorted =
      isHalfSolved
      && game.words.every((word, i) => i === game.words.length - 1 || this.differByOne(word.current, game.words[i + 1].current))

    if (!isSorted) return 'unsorted';
Enter fullscreen mode Exit fullscreen mode

The words could be in reverse order — that's fine.

Finally, confirm whether the edge words are correct. We also verify that the top edge word differs from the first middle word by one character, which ensures they're placed in the right order.

    const isSolved =
      isHalfSolved
      && isSorted
      && game.currentEdgeWords.every((word, i) => game.edgeWords.includes(word))
      && this.differByOne(game.currentEdgeWords[0], game.words[0].current);

    if (!isSolved) return 'sorted';
    return 'solved';
Enter fullscreen mode Exit fullscreen mode

That completes our gameStatus derived signal!

We need one more, which we'll call currentHint. This will hold the hint displayed to the user, determined by the current gameStatus and the focused cell index.

currentHint = computed(() => {
    if (this.gameStatus() === 'solved') return 'Solved!';
    if (this.gameStatus() === 'sorted') return 'Top + bottom: ' + this.game()!.edgeHint;
    if (this.gameStatus() === 'unsorted') return 'Sort the rows!';
    if (this.focused() === null) return undefined;

    return this.game()?.words.at(this.focused()!)!.hint;
  });
Enter fullscreen mode Exit fullscreen mode

This logic is fairly straightforward.

The service is nearly complete! Just three more methods are needed to update the game state.

First, add a replaceWord method that updates a single middle word:

  replaceWord(index: number, text: string) {
    this.game.update(game => (game && {
      ...game,
      words: game.words.map((word, i) => {
        if (index !== i) return word;
        return { ...word, current: text }
      })
    }))
  }
Enter fullscreen mode Exit fullscreen mode

The index parameter ranges from 0 to 4, since we're working with the five middle words.

Next, two similar methods for updating the edge words:

  replaceTop(word: string) {
    this.game.update(game => (game && {
      ...game,
      currentEdgeWords: [word, game.currentEdgeWords[1]] 
    }));
  }

  replaceBottom(word: string) {
    this.game.update(game => (game && {
      ...game,
      currentEdgeWords: [game.currentEdgeWords[0], word]
    }));
  }
Enter fullscreen mode Exit fullscreen mode

That wraps up the service! In the next article, we'll use the existing Word component along with the new GameService to render the game board, leveraging the Angular CDK for interactions.

When we're done, the result will look like this:

working demo