Create whack a mole game using RxJS and Angular

Connie Leung - Jan 1 '23 - - Dev Community

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
Enter fullscreen mode Exit fullscreen mode

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 { }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 {}
}
Enter fullscreen mode Exit fullscreen mode

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}` : '';
  }
}
Enter fullscreen mode Exit fullscreen mode

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 }`;
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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),
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

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')))
            }),
        );
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 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)
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

Declare subscription instance member and unsubscribe in ngDestroy()

subscription = new Subscription();

ngOnDestroy(): void {
    this.subscription.unsubscribe();
}
Enter fullscreen mode Exit fullscreen mode

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),
     );
}
Enter fullscreen mode Exit fullscreen mode

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)
);
Enter fullscreen mode Exit fullscreen mode

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)));
Enter fullscreen mode Exit fullscreen mode
  • 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);
Enter fullscreen mode Exit fullscreen mode

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:

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .