DEV Community

Cover image for Recreating LinkedIn’s Crossclimb Game with Angular - Part 2
Michele Stieven for This is Angular

Posted on

Recreating LinkedIn’s Crossclimb Game with Angular - Part 2

In the previous article we wrote the main component for our app: the Word component. Now, it's time to implement some game logic!


The models

Create a models/game.ts file which will store our models.

First, we have a GameInfo interface: this is what could be eventually be store on a server (we won't have one, but we'll pretend that we do). It stores the 5 middle words, each with its own hint, the 2 edge words (top and bottom) and a common hint.

/** 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

Then, we need a similar interface, which I called Game, which stores some additional strings: the user's current values.

/** 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 is what I imagined for the purpose of this demo, but it's not the only way to store the data.

As I said, we won't use an actual server, so let's export some fake data!

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

Remember that the 2 edge words share a common hint!

So, to recap, the user will have to guess these words in this exact order:

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

The service

We'll store pretty much all of our data in a service, so create a services/game.service.ts file.

This is where you may use a State Management solution, like NgRx. We'll keep it simple and use Angular Signals!

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

}
Enter fullscreen mode Exit fullscreen mode

This service will hold 2 states: 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

game will store the current Game object, and focused will store the index of the focused word (in our case, since we have 7 words, 0 to 6). We'll take advantage of this state to show the appropriate hint to the user.

Let's implement a method to initialize the game object. In a real scenario, you would perform an HTTP request, but we'll use 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

Here we're simply populating the current words with empty strings. So, if our words have 4 letters, the current words will be 4 empty characters each.

We're also randomizing the order of our words (see that Math.random()?), so that the user will be forced to sort them correctly!


The derived states

All of the info we need can be derived from our game and focused states, so let's create some derived states using computed.

First, let's calculate the word length, just for convenience.

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

It doesn't matter which word you choose, they have the same length.

We also need to know the current game status. The game could be in 5 different states:

  • idle (game not loaded yet)
  • error (not solved at all)
  • unsorted (middle words are solved, but not sorted)
  • sorted (middle words have been sorted)
  • solved (the edge words have been solved)

In order to calculate it, we need a helper method: let's call it differByOne(), and its job will be to tell us if 2 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

Now we can write our gameStatus derived state!

  gameStatus = computed(() => {

  });
Enter fullscreen mode Exit fullscreen mode

First, we check if there is a game at all. If we have no game, we return idle.

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

Then, we check if the middle words have been solved, and if not, we return error.

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

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

Then, we check if the middle words have been sorted correctly, and if not, we 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 arranged in reverse order, it doesn't matter.

Finally, we check if the edge words are correct (and we check that the top one and the first middle word differ by one character, to make sure they're written in the correct 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

This derived state is done!

We just need another one: we'll call it currentHint, and it will store the hint to show to the user based on the gameStatus and the focused 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 should be self-explanatory.

We're almost done with our service! We just need 3 additional methods to mutate our game state.

First, create a replaceWord method to change the value of one of the middle words:

  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 will go from 0 to 4 since we're only dealing with the 5 middle words.

Then, two similar methods to replace 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

And we're done! In the next article we'll take advantage of our Word component and our newly created GameService to display the actual game, with the help of the Angular CDK.

At the end, we'll have something like this:

working demo

Top comments (0)