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;
};
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;
};
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"'
};
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 {
}
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);
}
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]
});
}
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);
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;
}
With that in place, we can now build the gameStatus derived signal.
gameStatus = computed(() => {
});
First, check whether a game exists. If there's no game yet, return idle.
const game = this.game();
if (!game) return 'idle';
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';
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';
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';
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;
});
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 }
})
}))
}
The
indexparameter ranges from0to4, 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]
}));
}
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:

