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 letterisReadonly: prevents user edits; set totrueafter middle words are solvedisFinal: distinguishes the first and last words with unique stylingisLocked: 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 characterfocusedChange: 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
.containerclass, with an optionalfinalmodifier that alters the background color. - An empty array is generated via
[].constructor(letters().length), enabling@forto render the appropriate number of<input>s. - Each letter is captured into a template variable using
@let letter. - A reference variable
#inputis 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:
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');
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();
}
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;
}
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;
}
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 : ' '));
}
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();
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);
}
}
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);
}
}
}
The result should look like this:
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'>();
}
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" />
`,
...
})
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" />
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!


