DEV Community

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

Posted on

Recreating LinkedIn’s Crossclimb Game with Angular - Part 1

In this short series, we’ll use Angular to recreate the popular LinkedIn game Crossclimb, a fun word puzzle that’s perfect for showing off the power of Signals in modern Angular.

This project is not only a great coding exercise, but also an enjoyable way to explore reactive state management using Signals.
So, fire up your terminal, create a new Angular app, and let’s build together! 🚀

How the game works

If you’ve never played Crossclimb before, here’s a quick rundown:

The goal is to guess seven words using a set of hints to form a connected word chain. Each consecutive pair of words differs by exactly one character, for example, “code” → “cove” → “love”.

You’ll start by completing and correctly arranging the five middle words. Once that’s done, the first and last words unlock. They come with a hint to help you guess them, and they’re connected by a logical relationship.

When all seven words are correctly guessed, you’ve solved the puzzle! 🎉


The Word component

Feel free to use your favorite font for the project: for my demo, I've used Roboto, so I wrote this in my global styles:

@import url('https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100..900;1,100..900&display=swap');

body, * {
  font-family: "Roboto", sans-serif;
  font-optical-sizing: auto;
  font-style: normal;
  font-variation-settings: "wdth" 100;
}
Enter fullscreen mode Exit fullscreen mode

First, create the main component, called Word, in a file (eg. /components/word.ts):

import { Component } from "@angular/core";

@Component({
  selector: 'app-word',
  imports: [],
  template: `
  `,
  styles: ``
})
export class Word {

}
Enter fullscreen mode Exit fullscreen mode

On the surface, this component is pretty simple: it's a box containing multiple <input> elements in a row (one for each letter).

However, there are some behaviors we need to implement, for example:

  • Each <input> must contain a single letter (or none)
  • If the user writes a second letter, it must replace the previous one
  • After the user enters a letter, we must automatically focus the next <input>, so that the user can type the whole word easily
  • We must display a bottom border where if there is no letter, to indicate that it's missing

This is an example of how we'll use this component:

<app-word
  [letters]="[' ', ' ', ' ', ' ']"
  [isReadonly]="false"
  [isFinal]="false"
  [isLocked]="false"
  (lettersChange)="..."
  (focusedChange)="..."
  (lastKeydown)="..."
/>
Enter fullscreen mode Exit fullscreen mode

So, we need 4 inputs:

  • letters will be an array of characters (empty character for a missing letter)
  • isReadonly will stop the user from changing the letters (we'll set it to true once the user is done with the middle words)
  • isFinal will be used for the first and last words (just to style them with a different color)
  • isLocked will display a lock icon on top of the word (just for the first and last words, initially)

And two outputs:

  • lettersChange will give us back a new array of letters when the user changes one of them
  • focusedChange will tell us the index of the character which is currently focused by the user, we need this in order to display the correct hint. We don't actually need the index (we just need to know which word it is) but it may come in handy.
  • lastKeydown will be emitted when the user has typed something on the last <input>, we'll use this to focus on the next word automatically.

Let's write them, starting with the inputs.

export class Word {

  isReadonly = input(false);
  isFinal = input(false);
  isLocked = input(false);
  letters = model.required<string[]>();
  focused = model<number | null>(null);
}
Enter fullscreen mode Exit fullscreen mode

letters and focused are actually models, a particular type of input which can also be overridden by the component itself. Also, each model automatically creates an output with the same name but with the Change suffix: this means we get lettersChange and focusedChange for free! They'll emit the new value once the model value changes.

Then, create the missing output:

  lastKeydown = output();
Enter fullscreen mode Exit fullscreen mode

The template

It's time to display something! This will be the template for our component:

<div class="container" [class.final]="isFinal()">
  @for (_ of [].constructor(letters().length); let i = $index; track i) {
    @let letter = letters()[i];
    <input
      #input
      type="text"
      [class.with-border]="letter === ' ' && !isReadonly() && !isLocked()"
      [class.highlighted]="focused() === i && !isReadonly() && !isLocked()"
      [value]="letter === ' ' ? '' : letter"
      [readonly]="isReadonly()"
      (keydown)="onKeydown(i, $event)"
      (focus)="focused.set(i)"
      (blur)="focused.set(null)"
    >
  }
  @if (isLocked()) {
    <div class="overlay">
      <!--<app-icon icon="lock" />-->
    </div>
  }
</div>
Enter fullscreen mode Exit fullscreen mode

Here's what's going on...

  • The whole template is wrapped inside a .container element, with an optional class final which will simply change its background color.
  • We're creating an empty array with a trick ([].constructor(letters().length)) to be able to use @for to display the correct number of <input>s.
  • We're extracting each letter inside a template variable (@let letter).
  • We're using a reference variable #input to be able to grab the elements later.
  • We're applying some dynamic classes on the elements, just for styling.
  • We're binding properties and listening to events to change our states.
  • We're adding an overlay to display a lock icon on top of the element while the component is still locked (initially, the first and last word).

There are a few things missing: the keydown event handler, an <app-icon> component, and some styling.

First, let's create an empty handler just to make it work:

  onKeydown(index: number, e: KeyboardEvent) {}
Enter fullscreen mode Exit fullscreen mode

Then, apply some styling to our component. Feel free to use these styles:

  .container {
    position: relative;
    display: flex;
    justify-content: space-evenly;
    background: lightgrey;
    border-bottom: 2px solid grey;
    border-radius: 4px;
    padding: 10px;
  }
  input {
    border: none;
    background: transparent;
    width: 1.5em;
    text-align: center;
    outline: none;
    padding: 0;
    font-weight: bold;
  }
  .with-border {
    border-bottom: 2px solid grey;
  }
  .highlighted {
    border-bottom: 2px solid black;
  }
  .final {
    background: #FFCBA4;
  }
  .overlay {
    position: absolute;
    inset: 0;
    background: transparent;
    align-items: center;
    justify-content: center;
    display: flex;
  }
Enter fullscreen mode Exit fullscreen mode

Feel free to test this component! Just place it in your app:

@Component({
  selector: 'app-root',
  template: `
    <app-word [letters]="[' ', ' ', ' ', ' ']" />
  `,
  imports: [Word]
})
export class App {
}
Enter fullscreen mode Exit fullscreen mode

And you should see something like this:

word component

We just need to apply the correct logic when the user enters a letter: let's do it!


Handling the keydown event

We'll need the ability to focus on a specific <input>, and in order to do that we must grab a reference to them!

We've already applied an #input reference variable on our template, so we're able to grab all our <input> elements with the help of viewChildren:

import { ..., viewChildren } from "@angular/core";

// ...

  // Inside the component's class
  inputs = viewChildren<ElementRef<HTMLInputElement>>('input');
Enter fullscreen mode Exit fullscreen mode

This will give us a Signal which contains an array of ElementRefs, with all of our native elements inside.

Create a focus method that you'll use to focus on the correct element:

  focus(index: number) {
    this.inputs()[index]?.nativeElement.focus();
  }
Enter fullscreen mode Exit fullscreen mode

Now, it's time to implement the onKeydown() method! So position your cursor inside of it and write the following code... Feel free to test each step on your own!

First, if the word is readonly, we must do nothing and stop the event, so that nothing is written inside our element:

  if (this.isReadonly()) {
    e.preventDefault();
    return;
  }
Enter fullscreen mode Exit fullscreen mode

If the user takes advantage of the tab key to navigate the <input>s, allow it (don't prevent the event), but do nothing on the element.

  if(e.key === 'Tab') {
    return;
  }
Enter fullscreen mode Exit fullscreen mode

Then, the user could delete a character by using backspace or delete. We'll also add a simple check, so that if backspace is used on an empty character, we'll focus the previous <input>, to make it easier for the user.

  if (e.key === 'Backspace' || e.key === 'Delete') {
    if (e.key === 'Backspace' && this.letters()[index] === ' ') {
      this.focus(index - 1);
    }
    this.letters.update(letters => letters.map((l, i) => index !== i ? l : ' '));
  }
Enter fullscreen mode Exit fullscreen mode

At this point, stop the event so that nothing is written inside the input. We'll add the character ourself in our state, and Angular will automatically replace in on the DOM thanks to our [value] binding!

  e.preventDefault();
Enter fullscreen mode Exit fullscreen mode

Finally, we need to mutate our state with the new letter:

  if (isLetter && letter !== previousLetter) {
    this.letters.update(letters => letters.map((l, i) => index !== i ? l : letter));
    if (index === this.letters().length - 1) {
      this.lastKeydown.emit();
    } else {
      this.focus(index + 1);
    }
  }
Enter fullscreen mode Exit fullscreen mode

Notice that we're also focusing on the next <input>, and if there is none, we're just emitting the lastKeydown event. We'll use it later in another component!

Our main component is done! Feel free to test it out!

Here's the entire onKeydown() method:

  onKeydown(index: number, e: KeyboardEvent) {
    if (this.isReadonly()) {
      e.preventDefault();
      return;
    }
    if(e.key === 'Tab') {
      return;
    }
    if (e.key === 'Backspace' || e.key === 'Delete') {
      if (e.key === 'Backspace' && this.letters()[index] === ' ') {
        this.focus(index - 1);
      }
      this.letters.update(letters => letters.map((l, i) => index !== i ? l : ' '));
    }

    e.preventDefault();

    const isLetter = /^[a-zA-Z]$/.test(e.key);
    const letter = e.key.toUpperCase();
    const previousLetter = this.letters().at(index)!.toUpperCase();

    if (isLetter && letter !== previousLetter) {
      this.letters.update(letters => letters.map((l, i) => index !== i ? l : letter));
      if (index === this.letters().length - 1) {
        this.lastKeydown.emit();
      } else {
        this.focus(index + 1);
      }
    }
  }
Enter fullscreen mode Exit fullscreen mode

And this is what you should see:

done


The Icon component

We need an Icon component to display a couple of icons: one with a lock, and one with some bars (for the drag & drop functionality which we'll implement later). For the purpose of this exercise, you can just use the SVGs from Font Awesome, like this:

import { Component, input } from "@angular/core";

@Component({
  selector: 'app-icon',
  template: `
    @if (icon() === 'lock') {
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--!Font Awesome Free v7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M128 96l0 64 128 0 0-64c0-35.3-28.7-64-64-64s-64 28.7-64 64zM64 160l0-64C64 25.3 121.3-32 192-32S320 25.3 320 96l0 64c35.3 0 64 28.7 64 64l0 224c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 224c0-35.3 28.7-64 64-64z"/></svg>

    } @else {
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--!Font Awesome Free v7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M0 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 288c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z"/></svg>
    }
  `,
  styles: `
    svg {
      height: 1rem;
    }
  `
})
export class Icon {

  icon = input.required<'lock' | 'bars'>();
}
Enter fullscreen mode Exit fullscreen mode

Now, just remove the comment from the Word component template, and add Icon to the imports array:

import { Icon } from "./icon";

@Component({
  selector: 'app-word',
  imports: [Icon], // Add this
  template: `

    <!-- Change this: --->
    <!--<app-icon icon="lock" />-->

    <!-- To this: -->
    <app-icon icon="lock" />
  `,
  ...
})
Enter fullscreen mode Exit fullscreen mode

Try it out! Set isLocked to true and you should see something like this:

<app-word [letters]="[' ', ' ', ' ', ' ']" [isLocked]="true" />
Enter fullscreen mode Exit fullscreen mode

locked


Next steps

In the next articles of this series we'll implement the game logic and a drag & drop list using the Angular CDK. Stay tuned!

[Continues tomorrow!]

Top comments (0)