Angular, Signals and Reversi
The most recent versions reveal Angular is heading into an exciting future.

Torsten Becker
Senior Angular Developer
TL;DR
Recent Angular versions delivered a variety of new features. I wanted fo find out how a zoneless app based on signals and standalone components feels and performs. Having learned Angular in a game studio almost a decade ago a simple game like Reversi feels like a good choice to explore. Games have business logic, are complete apps, deal extensively with state and are highly interactive.
Read a travel story starting from an empty space, several summit conquests and final success.
What am I looking at?
A diverse stack of nested technologies including about 3 frameworks (Angular, Goolge's Lit and Next/React). Only little information crosses the React/Angular border, needed is however a ng-version attribute to avoid hydration warnings. LocalStorage is shared along the complete stack. You may want to clear the __reversi key behind the options tab in the 'danger zone'.
+-----------------------------------+ + Reversi | Carbon Web Components + +-----------------------------------+ + Angular Web Component + +-----------------------------------+ + React Component + +-----------------------------------+ + MDX/Next page + +-----------------------------------+ + Next.js + +-----------------------------------+
Building Angular or using Carbon Web Components are sufficiently documented elsewhere. Let's focus on new Angular recipes, signals and Reversi.
Principal Goals and Questions
- • Go Zoneless and save 30kB.
- • Only standalone conponents with ChangeDetectionStrategy.OnPush, no cdref.update(), no ngModules.
- • Multiple bot algorithms competing in endless matches.
- • How will Signals help with an options dialog?
- • How might an async game loop look like?
- • Distill best practices with Signals.
Why Reversi?
Traditionally games depend on proper state management. There are players, scores, boards, pieces just for the game and more for UI and device state. With 10e28 different board positions, how do you keep state consistent at all times?
The rules of Reversi are rather simple, however it is hard for a human players to predict game state a few moves in advance. Will field E2 - now black - remain black after the next 3 moves?
This cognitive challenge appears as a harder one compared to e.g. chess and allows very simple decently skilled bots with a few lines of code only. The most simple included here has LOC of 1: Pick a random move from the set of all legal moves.
Reversi Game logic
If needed behind the intro tab you'll find more about the game itself, it's quite intuitive with a digital board. You'll figure it out in seconds by just clicking at the blue circles.
+-----------------------+ + | | | | | + + | | | * | | + + | | B | W | * | + + | * | W | B | | + + | | * | | | + + | | | | | + +-----------------------+ Black to move, * = legal move
When a player makes a move four things happen,
- • First place a piece of the current player's color on the board,
- • Second find the set of all pieces which have to turn and
- • Third mark them for animation and
- • Fourth calculate a new set of legally moves.
This set may be empty allowing the current player another move. If none of the players have legals moves the game is over and the player with most pieces in their color wins. Otherwise the board is updated with a new piece config, legal moves, score and player.
In the game legal moves are indicated by blue circles. Assuming it is white's turn legal moves are determined by:
- • Collecting all empty fields
- • Filter all with at least one black neighbour
- • Does a ray exist in any of the 8 direction hitting a white piece with only black pieces in between?
The rays also nicely collect all pieces which will turn before next move.
It turns out the list of previous moves contains all needed game state. Everything else can be determined from this sequence.
Starting with e.g. "D5 D4 E5 E4 F5 F6 D3 C3" the algorithm recreates the whole board by placing and turning pieces move by move. Even after 60+ moves the calculations stay well below one millisecond without any optimizations. The algo occuppies no more than a few dozens lines of code.
Simple Bots
There are 3 bots selectable: Random, greedy and positional. Random is just one line and chooses a move from the list of legal moves.
move = pickRandom<TMove>(this.#game.legalmoves);
Greedy checks all legal moves and tries to turn as many pieces as possible. Only positional has some knowledge about the game and evaluates the resulting boards of all legal moves against this table:
const EVALBOARD = [
[ 30, -25, 10, 5, 5, 10, -25, 30,],
[-25, -25, 1, 1, 1, 1, -25, -25,],
[ 10, 1, 5, 2, 2, 5, 1, 10,],
[ 5, 1, 2, 1, 1, 2, 1, 5,],
[ 5, 1, 2, 1, 1, 2, 1, 5,],
[ 10, 1, 5, 2, 2, 5, 1, 10,],
[-25, -25, 1, 1, 1, 1, -25, -25,],
[ 30, -25, 10, 5, 5, 10, -25, 30,],
] as const;
Next level of intelligence would require even more game knowledge like an opening book.
Signals 1.0.1
Signals are fast, caching, glitch free, reactive primitives. Each signal holds a value and signals may depend on other signals. Below is a basic example using Angular semantics, actually Alien-Signals provides a similar API.
The JavaScript Signals standard proposal looks quite different and exposes a low level Watcher API, but no effects().
import { computed, signal, effect } from '@angular/core';
// init and assigns 1
const someNumber = signal(1);
// reads and assigns 1
const otherNumber = someNumber();
// creates depending signal, extends graph
const isSomeNumberOdd = computed( () => ( someNumber() & 1 ) === 0 );
// extends graph by another node
const paritySomeNumber = computed( () => isSomeNumberOdd() ? 'odd' : 'even' );
// logs 1, true
console.log(someNumber(), isSomeNumberOdd());
// change value
someNumber.set(2);
// log 2, false
console.log(someNumber(), isSomeNumberOdd());
// in a template
<div>Parity of {{ someNumber() }} is {{ paritySomeNumber() }}.</div>
// using effect
let greaterTen;
effect( () => {
greaterTen = someNumber() > 10;
});
// in a template
<div>someNumber is {{ greaterTen ? 'greater' : 'not greater' }} 10.</div>
// signals are glitch free
someNumber.set(3);
someNumber.set(4);
someNumber.set(5);
// in a template the values 3, 4 are never rendered, only the last value (5) appears in UI
<div>someNumber equals: {{ someNumber() }}.</div>
The Signal Graph
3 of these signals build a chain:
someNumber -> isSomeNumberOdd -> paritySomeNumber
If someNumber
changes all depend signals require recalculation, however this only happens if they are read.
Once updated a signal caches the new value and consecutive reads execute very fast.
Update notifications travel from left to right, recalculation is triggered from right to left.
This combined push & pull approach using a directed graph is the secret why signals behave performant.
Having your next app in mind a few critical questions should raise:
-
• How many of these graphs exist in an app? One, although signals won't penetrate iframe boundaries.
-
• When and from whom are these lambdas in computed() and effect() called? The implementing library decides. The Angular team changed the effect() timing substantially on micro task level from V18 to V19 and thus effect() is still in developer preview.
-
• How can I determine which signal triggers a given computed() signal? You can't. You may forget about a specific signal deep down the call stack.
-
• What stops a developer to create an unmaintainable mess with connected signals from all parts of an app? Nobody ;) If you feel like building a cathedral from connected signals, think twice. To cite an experienced webber: "With great power comes great responsibility"
Keeping Signals Private
To keep complextity contained it helps to declare all signals as private class fields or at least as protected. Computed signals are safe against external value changes, but exposing writable signals should be avoided.
I've put all signals behind getters, exposing the value only and in rare selected cases behind setters. In most cases there is no need to expose the signal, because during initial template interpolation Angular detects the reactive context and will automatically update all referenced templates on value changes. This pattern ensures only your components or services can alter a local signal's value.
export class Game {
#score: WritableSignal<TScore> = signal( [0, 0] );
public get score(): TScore { return this.#score(); }
}
// in a component template somewhere
<div>
{{ game.score[0] }} : {{ game.score[1] }}
</div>
Signals and Forms
In an ideal world input and output of forms share an identical type. So a form might read from and write to a single signal. However, this will probably trigger an endless loop during initialisation, easily avoided with a custom equality check:
#options: WritableSignal<TOptions> = signal(
this.initialOptions,
{ equal: this.#helper.deepEqual }
);
By default, signals check using referential equality with Object.is().
The options signal is an example of a public writable signal. Reading and updating work everywhere in the application.
public update (delta: Partial<TOptions>){
this.#options.set({ ...this.#options(), ...delta });
}
An async Game Loop
In theory there is only state and agents with their actions. All we need is a simple loop:
while ( true ) {
nextState = currentState.process(actions)
ui.render(nextState)
currentState = nextState
}
Yes, UI is a function of state. You should realize Angular runs the same loop driven by requestAnimationFrame behind the scene. Although achieving double-data-binding and implementing a sophisticated change detection add a lot of details.
Back to Reversi things get a little bit more complicated in practice with animations. Is every animation frame a part of the state?
Re-entrance is another challenge: The user may interrupt this loop any time by starting a new game and then discarded move animations should stop instantly.
Also choosing another tab like options pauses bot moves and thus the loop during a game. After several experiments I've found a concise way to mix sync with async code.
async function play (config) {
const [ skip, skipable, skipableSleep ] = this.#helper.skipables();
const gamerestart = () => {
skip();
this.play(config);
};
ui.on('restart', gamerestart);
// simplified game loop
while ( true ) {
if ( game.isover ) {
// finalize game
break;
} else if ( game.playerIsBot ) {
// wait for play tab, breaks on game restart
if ( await skipable(ui.waitforPanel('play')) === 'break' ) {
break;
}
// simulate a thinking bot or break
if ( await skipableSleep(timeThinking) === 'break' ) {
break;
}
// update game with next move
move = ai.calcMove();
game.update( move );
// returns early, if skipped
await board.animateMove( skipable );
continue;
} else if ( game.playerIsHuman ) {
event = await skipable( ui.waitforMove() );
if ( event === 'break' ) { break; }
move = event;
// update game and animate...
}
}
}
Although play() calls itself after a restart has been initiated, skip() ensures all async operations are cancelled on the spot and the game loop ends very fast.
At the heart of skipables you'll find Promise.race() with a normal execution path and a short circuit. AbortSignal() works similar, but the API is not flexible enough for above use case.
Best practices and final thoughts
- • Signals declared private help to isolate complextity.
- • No issues found with standalone components using onPush change detection only.
- • Neither zone.js nor ngModule are missed.
- • The new control flow syntax structures templates significantly better.
- • Parent/Child component access with viewChild.required() drops existence checks.
- • Keeping complexity in mind, you'll get very far without a state management library.
Looking back it was an interesting challenge to start with an empty folder and challenge all new Angular features to comply with the architecture I had envisioned.
In more than a few cases I had to forget former acquired patterns and found the solution is now more simple than expeted. At no point I had to look up implementation details, because everything behaved as documented.
I'm looking forward to the next project going all in with Signals :)