Introduction
This is day 30 of Wes Bos’s JavaScript 30 challenge and I am going to use RxJS and Angular to create whack a mole game. The whack a mole game defines multiple streams to perform many UI tasks at the same time:
- wait 3 seconds before the game starts and the first mole appears
- build a game loop to show a mole at random hole and random time
- increment score when mole is clicked
- finish the game after 10 seconds
In this blog post, I describe how to create RXJS streams to handle intensive UI tasks described above in a step-by-step manner. Ultimately, we create whack a mole game that does not require to write a lot of codes and RxJS custom operators.
Create a new Angular project
ng generate application day30-whack-a-mole
Create Game feature module
First, we create a game feature module and import it into AppModule. The feature module encapsulates MoleComponent, WhackAMoleMessagePipe and RemainingTimePipe.
Import GameModule in AppModule
// game.module.ts
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { MoleComponent } from './mole/mole.component';
import { RemainingTimePipe, WhackAMoleMessagePipe } from './pipes';
@NgModule({
declarations: [
MoleComponent,
WhackAMoleMessagePipe,
RemainingTimePipe
],
imports: [
CommonModule
],
exports: [
MoleComponent
]
})
export class GameModule { }
// app.module.ts
import { APP_BASE_HREF, PlatformLocation } from '@angular/common';
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import { GameModule } from './game';
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
GameModule
],
providers: [
{
provide: APP_BASE_HREF,
useFactory: (platformLocation: PlatformLocation) => platformLocation.getBaseHrefFromDOM(),
deps: [PlatformLocation]
}
],
bootstrap: [AppComponent]
})
export class AppModule { }
Declare Game components in feature module
In Game feature module, we declare component, pipes and RxJS custom operators to create whack a mole game. All game logic happens in MoleComponent and the component calls RemainingTimePipe and WhackAMoleMessagePipe to render messages. To make RxJS streams shorter and more declarative to comprehend, RxJS logic is refactored into custom operators, peep, trackGameTime and whackAMole respectively.
src/app
├── app.component.ts
├── app.module.ts
└── game
├── custom-operators
│ ├── index.ts
│ ├── peep.operator.ts
│ ├── time-tracker.operator.ts
│ └── whack-a-mole.operator.ts``
├── game.module.ts
├── index.ts
├── mole
│ ├── mole.component.scss
│ ├── mole.component.ts
│ └── mole.enum.ts
└── pipes
├── index.ts
├── remaining-time.pipe.ts
└── whack-a-mole-message.pipe.ts
MoleComponent
is the centerpiece of this game and the component tag is <app-mole>.
// mole.component.ts
import { APP_BASE_HREF } from '@angular/common';
import { ChangeDetectionStrategy, Component, ElementRef, Inject, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { BehaviorSubject, Observable, Subscription, concatMap, delay, fromEvent, map, merge, scan, shareReplay, startWith, take, takeUntil, timer } from 'rxjs';
import { peep, trackGameTime, whackAMole } from '../custom-operators';
import { SCORE_ACTION } from './mole.enum';
@Component({
selector: 'app-mole',
template: `
<h1>Whack-a-mole! <span class="score">{{ score$ | async }}</span></h1>
<button #start class="start">Start!</button>
<ng-container *ngIf="{ timeLeft: timeLeft$ | async } as data">
<span class="duration">{{ data.timeLeft | remainingTime }}</span>
</ng-container>
<ng-container *ngIf="{ delayGameMsg: delayGameMsg$ | async } as data">
<span class="message">{{ data.delayGameMsg | whackAMoleMessage }}</span>
</ng-container>
<div class="game">
<div class="hole hole1" [style]="'--hole-image:' + holeSrc" #hole1>
<div class="mole" [style]="'--mole-image:' + moleSrc" #mole1></div>
</div>
<div class="hole hole2" [style]="'--hole-image:' + holeSrc" #hole2>
<div class="mole" [style]="'--mole-image:' + moleSrc" #mole2></div>
</div>
<div class="hole hole3" [style]="'--hole-image:' + holeSrc" #hole3>
<div class="mole" [style]="'--mole-image:' + moleSrc" #mole3></div>
</div>
<div class="hole hole4" [style]="'--hole-image:' + holeSrc" #hole4>
<div class="mole" [style]="'--mole-image:' + moleSrc" #mole4></div>
</div>
<div class="hole hole5" [style]="'--hole-image:' + holeSrc" #hole5>
<div class="mole" [style]="'--mole-image:' + moleSrc" #mole5></div>
</div>
<div class="hole hole6" [style]="'--hole-image:' + holeSrc" #hole6>
<div class="mole" [style]="'--mole-image:' + moleSrc" #mole6></div>
</div>
</div>`,
styleUrls: ['mole.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class MoleComponent implements OnInit, OnDestroy {
@ViewChild('start', { static: true, read: ElementRef })
startButton!: ElementRef<HTMLButtonElement>;
@ViewChild('hole1', { static: true, read: ElementRef })
hole1!: ElementRef<HTMLDivElement>;
... repeat the same step for hole2, hole3, hole4, hole5 and hole6 ...
@ViewChild('mole1', { static: true, read: ElementRef })
mole1!: ElementRef<HTMLDivElement>;
... repeat the same step for mole2, mole3, mole4, mole5 and mole6 ...
score$!: Observable<number>;
timeLeft$!: Observable<number>;
delayGameMsg$!: Observable<number>
subscription = new Subscription();
lastHoleUpdated = new BehaviorSubject<number>(-1);
constructor(@Inject(APP_BASE_HREF) private baseHref: string) { }
ngOnInit(): void {
this.score$ = of(0);
this.delayGameMsg = of(3);
this.timeLeft$ = of(10);
}
get moleSrc(): string {
return this.buildImage('mole.svg');
}
get holeSrc(): string {
return this.buildImage('dirt.svg');
}
private buildImage(image: string) {
const isEndWithSlash = this.baseHref.endsWith('/');
const imagePath = `${this.baseHref}${isEndWithSlash ? '' : '/'}assets/images/${image}`;
return `url('${imagePath}')`
}
ngOnDestroy(): void {}
}
whackAMoleMessagePipe
displays the count down to allow the player to prepare before the game actually starts.
// whack-a-mole-message.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'whackAMoleMessage'
})
export class WhackAMoleMessagePipe implements PipeTransform {
transform(seconds: number | null): string {
if (seconds == null) {
return '';
}
const units = seconds > 1 ? 'seconds' : 'second';
return seconds > 0 ? `Whack a mole will begin in ${seconds} ${units}` : '';
}
}
RemainingTimePipe
displays the time remained in the game.
// remaining-time.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'remainingTime'
})
export class RemainingTimePipe implements PipeTransform {
transform(seconds: number | null): string {
if (seconds == null) {
return '';
}
const units = seconds > 1 ? 'seconds' : 'second';
return `Time remained: ${seconds} ${ units }`;
}
}
Next, I delete boilerplate codes in AppComponent and render MoleComponent in inline template.
// app.component.ts
import { Component } from '@angular/core';
import { Title } from '@angular/platform-browser';
@Component({
selector: 'app-root',
template: '<app-mole></app-mole>',
styles: [`
:host {
display: block;
}
`]
})
export class AppComponent {
title = 'Day30 Wrack a mole';
constructor(titleService: Title) {
titleService.setTitle(this.title);
}
}
Define RxJS custom decorators to simplify game logic
The game has 3 RxJS custom operators, trackGameTime, peep and whackAMole. I refactor the logic out of MoleComponent to maintain a short ngOnInit method.
trackGameTime
is a custom operator to start the count down from gameDurationInSeconds and return the remaining seconds
// time-tracker.operator.ts
import { Observable, concatMap, scan, startWith, take, timer } from 'rxjs';
export function trackGameTime<T>(gameDurationInSeconds = 10) {
return function(source: Observable<T>) {
return source.pipe(
concatMap(() => timer(0, 1000).pipe(
take(gameDurationInSeconds),
scan((acc) => acc - 1, gameDurationInSeconds),
)),
startWith(gameDurationInSeconds),
)
}
}
peep
selects a random mole to display in a random hole for a random time. The operator adds and removes CSS class in order to bring out the mole and hide it after some amount of time.
// peep.operator.ts
import { ElementRef } from '@angular/core';
import { BehaviorSubject, Observable, concatMap, map, tap, timer } from 'rxjs';
function randomTime(min: number, max: number): number {
return Math.round(Math.random() * (max - min) + min);
}
function randomHole(holes: ElementRef<HTMLDivElement>[], lastHole: number): number {
const idx = Math.floor(Math.random() * holes.length);
console.log('In randomHole', 'lastHole', lastHole, 'next hole', idx);
if (idx === lastHole) {
console.log('Ah nah thats the same one bud');
return randomHole(holes, lastHole);
}
return idx;
}
export function peep<T extends number>(holes: ElementRef<HTMLDivElement>[], minUpTime: number, maxUpTime: number) {
return function(source: Observable<T>) {
return source.pipe(
map((lastHole) => ({
upTime: randomTime(minUpTime, maxUpTime),
holeIdx: randomHole(holes, lastHole),
})),
concatMap(({ upTime, holeIdx }) => {
if (source instanceof BehaviorSubject) {
source.next(holeIdx);
}
const hole = holes[holeIdx].nativeElement;
hole.classList.add('up');
return timer(upTime).pipe(tap(() => hole.classList.remove('up')))
}),
);
}
}
- map selects a random mole and random hole
- concatMap updates the last hole in the behaviorSubject to ensure the same hole is not picked the next time
- timer delays by upTime and fires one time to remove the CSS class to remove the mole
whackAMole
listens to the click event on mole, removes the mole and increments the score by 1
// whack-a-mole.operator.ts
import { Observable, filter, map, tap } from 'rxjs';
import { SCORE_ACTION } from '../mole/mole.enum';
export function whackAMole<T extends HTMLElement>(nativeElement: T) {
return function(source: Observable<Event>) {
return source.pipe(
filter(event => event.isTrusted),
tap(() => {
if (nativeElement.parentElement) {
nativeElement.parentElement.classList.remove('up');
}
}),
map(() => SCORE_ACTION.ADD)
);
}
}
Create whack a mole game using RxJS streams
Compose the RxJS streams from the easiest to the most difficult
- calculate score
- delay the game by 3 seconds after button click
- display count down from 3, 2, 1 and 0
- display remaining time in the guard
- create game loop that runs 10 seconds and finishes the game
Initialize HTML elements
Use ViewChild to obtain references to button, moles and holes
@ViewChild('start', { static: true, read: ElementRef })
startButton!: ElementRef<HTMLButtonElement>;
@ViewChild('hole1', { static: true, read: ElementRef })
hole1!: ElementRef<HTMLDivElement>;
@ViewChild('hole2', { static: true, read: ElementRef })
hole2!: ElementRef<HTMLDivElement>;
@ViewChild('hole3', { static: true, read: ElementRef })
hole3!: ElementRef<HTMLDivElement>;
@ViewChild('hole4', { static: true, read: ElementRef })
hole4!: ElementRef<HTMLDivElement>;
@ViewChild('hole5', { static: true, read: ElementRef })
hole5!: ElementRef<HTMLDivElement>;
@ViewChild('hole6', { static: true, read: ElementRef })
hole6!: ElementRef<HTMLDivElement>;
@ViewChild('mole1', { static: true, read: ElementRef })
mole1!: ElementRef<HTMLDivElement>;
@ViewChild('mole2', { static: true, read: ElementRef })
mole2!: ElementRef<HTMLDivElement>;
@ViewChild('mole3', { static: true, read: ElementRef })
mole3!: ElementRef<HTMLDivElement>;
@ViewChild('mole4', { static: true, read: ElementRef })
mole4!: ElementRef<HTMLDivElement>;
@ViewChild('mole5', { static: true, read: ElementRef })
mole5!: ElementRef<HTMLDivElement>;
@ViewChild('mole6', { static: true, read: ElementRef })
mole6!: ElementRef<HTMLDivElement>;
Declare subscription instance member and unsubscribe in ngDestroy()
subscription = new Subscription();
ngOnDestroy(): void {
this.subscription.unsubscribe();
}
Calculate game score
Update this.score$ observable to calculate the game score in ngOnInit().
private createMoleClickedObservables(...moles: ElementRef<HTMLDivElement>[]): Observable<SCORE_ACTION>[] {
return moles.map(({ nativeElement }) => fromEvent(nativeElement, 'click').pipe(whackAMole(nativeElement)));
}
ngOnInit(): void {
const molesClickedArray$ = this.createMoleClickedObservables(this.mole1, this.mole2, this.mole3, this.mole4, this.mole5, this.mole6);
const startButtonClicked$ = fromEvent(this.startButton.nativeElement, 'click')
.pipe(
map(() => SCORE_ACTION.RESET),
shareReplay(1)
);
this.score$ = merge(...molesClickedArray$, startButtonClicked$)
.pipe(
scan((score, action) => action === SCORE_ACTION.RESET ? 0 : score + 1, 0),
startWith(0),
);
}
Score will update when player clicks start button to reset game or whacks a mole to increment the score.
Delay start time of the game
I want the game to start 3 seconds after the button is clicked to allow the player to settle down. Therefore, I define delayGameMsg$ and delayGameStart$ observables.
const delayTime = 3;
this.delayGameMsg$ = startButtonClicked$.pipe(
concatMap(() => timer(0, 1000)
.pipe(
take(delayTime + 1),
map((value) => delayTime - value),
))
);
const delayGameStart$ = startButtonClicked$.pipe(
delay(delayTime * 1000),
shareReplay(1)
);
this.delayGameMsg$ is a timer that emits 3, 2, 1 and 0, and each value is fed to whackAMoleMessage pipe to display “Whack a mole will begin in X seconds”.
delayGameStart$ delays 3 seconds after the button click before the first mole appears.
Display the remaining time in the game
After the delay, I launch another timer Observable to show the game clock. The game clock sets to 10 seconds initially and goes down to 0.
const gameDuration = 10;
const resetTime$ = startButtonClicked$.pipe(map(() => gameDuration));
this.timeLeft$ = merge(resetTime$, delayGameStart$.pipe(trackGameTime(gameDuration)));
-
resetTime$
resets the clock to 10 seconds whenever the start button is clicked -
delayGameStart$.pipe(trackGameTime(gameDuration)
changes the game clock after the 3 seconds delay -
merge
merges the Observables to display the remaining time in the game
Create game loop
lastHoleUpdated = new BehaviorSubject<number>(-1);
const holes = [this.hole1, this.hole2, this.hole3, this.hole4, this.hole5, this.hole6];
const createGame = delayGameStart$.pipe(concatMap(() => this.lastHoleUpdated
.pipe(
peep(holes, 350, 1000),
takeUntil(timer(gameDuration * 1000))
)
))
.subscribe();
this.subscription.add(createGame);
After 3 seconds elapse, the game loop starts and continues for 10 seconds.
this.lastHoleUpdated
is a behavior subject that keeps track of the last chosen hole. When this.lastHoleUpdated receives a new value, it calls peep operator to display another mole between 350 milliseconds and 1 second. takeUntil(timer(gameDuration * 1000))
ends the game loop after 10 seconds.
Since the inner observable returns a new observable, I use concatMap
to return the result of this.lastHoleUpdated.pipe(....)
.
Add createGame
to this.subscription and unsubscribe it in ngOnDestroy
.
The example is done and we have built a whack a mole game successfully.
Final Thoughts
In this post, I show how to use RxJS and Angular to create whack a mole game. The first takeaway is to compose multiple RxJS streams together to implement game loop. The second takeaway is to encapsulate RxJS operators to custom operators to define streams that are lean and easy to understand. The final takeaway is to use async pipe to resolve observables such that developers do not have to clean up subscriptions.
This is the end of the blog post and I hope you like the content and continue to follow my learning experience in Angular and other technologies.
Resources:
- Repo: https://github.com/railsstudent/ng-rxjs-30/tree/main/projects/day30-whack-a-mole
- Live demo: https://railsstudent.github.io/ng-rxjs-30/day30-whack-a-mole/
- Wes Bos’s JavaScript 30 Challenge: https://github.com/wesbos/JavaScript30
Top comments (0)