Understanding Crossclimb: The Game Mechanics

Crossclimb challenges players with a simple yet engaging premise: deduce seven words connected in a sequence where neighboring words differ by only one character. For instance, “code” → “cove” → “love” forms a valid chain.

To begin, players focus on the five middle words, completing and arranging them in the correct order. Once these are solved, the first and last words—accompanied by hints and connected by a conceptual link—are unlocked. Successfully guessing all seven words wins the game. 🎉

Building the Word Component

For this project, my demo uses the Roboto font, configured in global styles as follows:

@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;
}

Start by creating a primary component named Word, placed in a file such as /components/word.ts:

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

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

}

At its core, the component is a simple layout: a row of <input> elements, one per letter. However, several behaviors need careful implementation:

  • Each <input> accepts only one character, or remains empty
  • Typing a new character over an existing one replaces it
  • After input, focus automatically moves to the next <input> to facilitate rapid typing
  • A bottom border appears, indicating an empty spot where a letter is expected

Here's a practical usage example of the component:

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

The component requires four inputs:

  • letters: an array of characters, where an empty string represents a missing letter
  • isReadonly: prevents user edits; set to true after middle words are solved
  • isFinal: distinguishes the first and last words with unique styling
  • isLocked: displays a lock icon over the word while it's initially locked

And it exposes three outputs:

  • lettersChange: emits a fresh letters array whenever the user modifies a character
  • focusedChange: reports the index of the currently focused input, useful for showing the right hint (the index itself isn't critical, merely the word identity, but could be beneficial)
  • lastKeydown: fires on input within the final <input>, enabling automatic focus shift to the next word

Let's define these inputs first:

export class Word {

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

Here, letters and focused are implemented as models—inputs that the component can also update. Each model conveniently generates an output with the Change suffix, giving us lettersChange and focusedChange without additional code; they trigger whenever the model's value changes.

Next, create the missing output manually:

  lastKeydown = output();

Crafting the Template

Now, let's build the template that brings everything to life:

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

Here's what it accomplishes:

  • Root element uses a .container class, with an optional final modifier that alters the background color.
  • An empty array is generated via [].constructor(letters().length), enabling @for to render the appropriate number of <input>s.
  • Each letter is captured into a template variable using @let letter.
  • A reference variable #input is set to access the elements later.
  • Dynamic classes are applied for styling purposes.
  • Property bindings and event listeners are attached to update state.
  • An overlay presents a lock icon while the component remains locked, initial for first and last words.

Some pieces are yet to be added: a keydown handler, an <app-icon> component, and styles.

Begin with a placeholder handler to ensure functionality:

  onKeydown(index: number, e: KeyboardEvent) {}

Then, add styles, which you're free to adapt:

  .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;
  }

To test, embed the component in your application:

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

The result should resemble this:

word component

The last step is implementing the letter-input logic. Let's proceed!

Responding to keydown Input

To direct focus to a particular <input>, we first need a way to reference those elements.

Since we've already tagged each input with an #input template variable, we can collect them all using viewChildren:

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

// ...

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

This returns a Signal holding an array of ElementRef instances, each pointing to a native input element.

Next, define a focus method that we can call to place the cursor in the intended input:

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

With that in place, we can build out the onKeydown() logic. Place your cursor inside the method and add the following checks. Testing as you go is encouraged.

The first guard: if the word is marked as readonly, we should prevent the default behavior and do nothing, ensuring no characters get entered:

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

If the user presses the tab key to move between inputs, we want the default navigation to happen, so we don't call preventDefault(). We simply return early without altering anything.

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

Next, we handle deletion. If the user hits backspace or delete, we clear the character. As a convenience, when backspace is pressed on an empty field, we shift focus to the preceding <input>.

  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 stage, we also stop the event's default action. We're managing the character insertion ourselves in our component state. Because we've bound the input's [value], Angular will automatically sync the DOM with our state change.

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

The final step is to update our reactive state with the new character

  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

Keep in mind that we also move focus to the next <input>. When there isn't one, we emit the lastKeydown event, which will be consumed by a parent component later.

That's the main logic for this component! You can now run it and try typing.

For reference, here's the complete 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

The result should look like this:

done


Creating the Icon Component

To display a lock icon and a handle icon (for the future drag-and-drop feature), we'll build a small, reusable Icon component. For simplicity, you can use the SVGs provided by Font Awesome:

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, all that's left is to uncomment the <app-icon> tag in the Word component's template and ensure Icon is included in its 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

Go ahead and give it a spin! If you switch isLocked to true, you'll see the lock icon appear:

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

locked


What's Next

The upcoming parts of this series will dive into the game's core logic and demonstrate how to build a drag-and-drop list with the Angular CDK. We'll see you there!