Skip to main content
Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.

Playing Zoom Bingo In Angular 10.1.2

By Ben Nadel on

The other day, I was on a call that felt more like a reverse Turning Test in which the human on the other end was trying to their hardest to imitate the cold, detached logic of a machine. It conjured up all the frustration that one feels while navigating an IVR (Interactive Voice Response) decision tree; but, with the added insult that I was, in reality, talking to a sentient being, albeit one that refused to acknowledge its own sentience. It made me think of the "Zoom Bingo" games that have taken root in this Pandemic landscape. And, as a way to process some of my dissatisfaction that I'm feeling, I thought it might be fun to try and build one of those Zoom Bingo games in Angular 10.1.2.

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

CONTENT CREDIT: The goal of this experiment was simply to build the game, not to come up with the game phrases. As such, I have "borrowed" the phrases for this demo from the Vault.com article: Zoom Call Bingo (With Cards!) for Your Next Meeting. Their article has nice printable cards that you should check out!

Because this demo is a client-side-only application built with Angular 10, I wanted the foundational state of the game to be stored in the URL. This way, one could configure the phrases within the game and then copy-paste the URL to a co-worker who would then be able to see the same game, albeit with a randomized selection of phrases.

To that end, I'm storing the configured phrases as a Base64Url-encoded value on the window.location.hash. At first, I tried to use the Location service provided by Angular. But, this service appears to be more of foundational part of the Routing than it does a usable abstraction over the window.location object. In the end, reading from and writing to the window.location API was an easy implementation detail.

The Zoom Bingo game is composed of three components:

  • The AppComponent, which glues it all together.
  • The BingoBoardComponent, which renders the configured phrases as an interactive 5x5 Bingo board.
  • The FormComponent, which allows the phrases for the game to be configured.

And, as an experimental feature, I'm using the html2canvas library to take a screenshot of the board and render it as an <img> tag on the page. This way, you can share your Bingo victories with the rest of your team.

The demo isn't very complicated; but, there are a few moving parts. So, before we look at the code, let's look at the outcome that we're trying to achieve. The following image showcases the general flow of the game, including the screenshot functionality:

Now that we see what kind of interactive outcome we're trying to achieve, let's take a look at some code. And, I think it might be easiest to look at the code from the bottom-up. The AppComponent is the glue that binds it all together; so, before we look at the root component, let's look at the smaller components that we're gluing together.

First up, the BingoBoardComponent. This component takes an input array of [phrases] and then selects 25 random values which it displays using CSS Grid. If it does not receive 25 phrases from which to select, it will pad the values with the filler phrase, (Free Space):

// Import the core angular services.
import { ChangeDetectionStrategy } from "@angular/core";
import { Component } from "@angular/core";

// Import the application components and services.
import { Utilities } from "./utilities";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

// I define the minimum number of spaces that there can be on the bingo board. If an
// insufficient number of phrases are passed-in, the rest of the spaces will be padded
// with the filler phrase.
var MIN_LENGTH = 25;
var FILLER_PHRASE = "(Free Space)";

interface SelectedIndices {
	[ key: string ]: boolean;
}

@Component({
	selector: "app-bingo-board",
	inputs: [ "phrases" ],
	changeDetection: ChangeDetectionStrategy.OnPush,
	styleUrls: [ "./bingo-board.component.less" ],
	templateUrl: "./bingo-board.component.html"
})
export class BingoBoardComponent {

	public phrases: string[];
	public selectedIndices: SelectedIndices;
	public spaces: string[];

	// I initialize the bingo-board component.
	constructor() {

		this.phrases = [];
		this.selectedIndices = Object.create( null );
		this.spaces = [];

	}

	// ---
	// PUBLIC METHODS.
	// ---

	// I get called when any of the input bindings have been updated.
	public ngOnChanges() : void {

		this.selectedIndices = Object.create( null );
		this.spaces = this.selectRandomPhrases();

	}


	// I toggle the space at the given index.
	public toggleIndex( index: number ) : void {

		this.selectedIndices[ index ] = ! this.selectedIndices[ index ];

	}

	// ---
	// PRIVATE METHODS.
	// ---

	// I select a randomly-sorted assortment of phrases for the board.
	private selectRandomPhrases() : string[] {

		var selectedPhrases = this.phrases.slice();

		while ( selectedPhrases.length < MIN_LENGTH ) {

			selectedPhrases.push( FILLER_PHRASE );

		}

		return( Utilities.arrayShuffle( selectedPhrases ).slice( 0, MIN_LENGTH ) );

	}

}

The selectedIndices index is just a look-up that stores which spaces have been toggled by the user. And, thanks to the magic of CSS Grid, the View template for this component is super simple:

<ul id="bingo-board" class="card">
	<li
		*ngFor="let space of spaces; index as index ;"
		(click)="toggleIndex( index )"
		class="space"
		[class.space--selected]="selectedIndices[ index ]">

		{{ space }}

	</li>
</ul>

The next low-level component is the FormComponent. Like the BingoBoardComponent, the FormComponent also takes an array input of [phrases] which it uses to initialize a collection of form fields. This allows the user to add or alter as many phrases as they want. And, when when the submit the form, this component emits a (phrasesChange) event:

// Import the core angular services.
import { ChangeDetectionStrategy } from "@angular/core";
import { Component } from "@angular/core";
import { EventEmitter } from "@angular/core";
import { SimpleChanges } from "@angular/core";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

interface PhraseOption {
	id: number;
	name: string;
	value: string;
}

@Component({
	selector: "app-form",
	inputs: [ "phrases" ],
	outputs: [ "phrasesChangeEvents: phrasesChange" ],
	changeDetection: ChangeDetectionStrategy.OnPush,
	styleUrls: [ "./form.component.less" ],
	templateUrl: "./form.component.html"
})
export class FormComponent {

	public options: PhraseOption[];
	public phrases: string[];
	public phrasesChangeEvents: EventEmitter<string[]>;

	// I initialize the form component.
	constructor() {

		this.options = [];
		this.phrases = [];
		this.phrasesChangeEvents = new EventEmitter();

	}

	// ---
	// PUBLIC METHODS.
	// ---

	// I add an empty option to the input list.
	public addOption() : void {

		var nextID = this.options.length;

		this.options.push({
			id: nextID,
			name: `phrase_${ nextID }`,
			value: ""
		});

	}


	// I add a number of empty options to the input list.
	public addOptions() : void {

		for ( var i = 0 ; i < 5 ; i++ ) {

			this.addOption();

		}

	}


	// I get called once after the inputs have been bound for the first time.
	public ngOnInit() : void {

		this.options = this.phrases.map(
			( phrase, index ) => {

				return({
					id: index,
					name: `phrase_${ index }`,
					value: phrase
				});

			}
		);

		// Let's encourage the creation of at least 25-phrases. The user doesn't need to
		// include all of them - phrases will be padded if an insufficient number is
		// provided. But, it would be best if 25+ were defined.
		while ( this.options.length < 25 ) {

			this.addOption();

		}

	}


	// I process the form, emitting a new collection of phrases to be used in the game.
	public processForm() : void {

		var newPhrases = this.options
			.map(
				( option ) => {

					return( option.value.trim() );

				}
			)
			.filter(
				( phrase ) => {

					return( !! phrase );

				}
			)
		;

		this.phrasesChangeEvents.emit( newPhrases );

	}

}

You'll notice that there is no special "form data" object in this component - just an array of PhraseOption instances. That's because template-driven forms in Angular have all the power of reactive forms; but, without all of the ceremony of having to configure data ahead of time. This way, we just loop over our values and let ngModel do the heavy-lifting for us:

<p>
	To <em>delete</em> an option, just leave it blank.
</p>

<form (submit)="processForm()">

	<div *ngFor="let option of options" class="option">

		<!--
			NOTE: Since we're using template-driven forms, we have to provide the [name]
			property so that the ngModel control can register itself with the parent
			form. In our case, this is basically a "throw away" value since we never
			reference it explicitly; but, it is required for compilation.
		-->
		<input
			type="text"
			[name]="option.name"
			[(ngModel)]="option.value"
			class="input"
		/>

	</div>

	<div class="actions">

		<button type="submit">
			Submit Changes
		</button>

		<a (click)="addOptions()">
			Add more options
		</a>

	</div>

</form>

So far, pretty straightforward - we have a BingoBoardComponent and a FormComponent, both of which use OnPush change-detection and work exclusively using input and output bindings. On their own, each is fairly easy to reason about. Now, let's look at the AppComponent - the glue that holds this all together.

The AppComponent has a few responsibilities:

  • It handles the game mode (playing or editing).
  • It reads-in game state from the URL.
  • It persists game state to the URL (for sharing).
  • It performs the Base64Url encoding and decoding.
  • It takes a screenshot of the bingo board using html2canvas.

The AppComponent comes with a default set of phrases, which it will use (and persist to the URL) if no other phrases are provided in the URL.

Half of the complexity in this component ends-up being the screenshot invocation. So, if you are not concerned with that, feel free to skip-over the takeScreenshot() method:

// Import the core angular services.
import { Component } from "@angular/core";
import html2canvas from "html2canvas";

// Import the application components and services.
import { Utilities } from "./utilities";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

type Mode = "edit" | "play";

@Component({
	selector: "app-root",
	styleUrls: [ "./app.component.less" ],
	templateUrl: "./app.component.html"
})
export class AppComponent {

	public mode: Mode;
	public phrases: string[];
	public screenshotUrl: string;

	// I initialize the app component.
	constructor() {

		this.mode = "play";
		this.screenshotUrl = "";

		// Since coming up with phrases was not the "goal" of this code kata, I borrowed
		// the phrases from this blog post on Vault.com about Zoom Bingo:
		// --
		// https://www.vault.com/blogs/coronavirus/zoom-call-bingo-with-cards-for-your-next-meeting
		this.phrases = [
			"Pet photobomb",
			"Awkward silence",
			"House plant in background",
			"Obviously texting off screen",
			"Dog barking",
			"Someone walks in on meeting",
			"Hey guys, I have another call",
			"Someone forgets to unmute",
			"Two people talk at the same time",
			"Let's take that offline",
			"Taking the meeting in bed",
			"Someone has a 'Hard Stop'",
			"Kids yelling in background",
			"Can't turn off 'fun' background",
			"Let's circle back to that",
			"Firetruck / Ambulance siren",
			"Action figures / toys in background",
			"Robot voice",
			"Turns off camera halfway through",
			"Only every other word comes through",
			"Echo",
			"Can everyone seen my screen?",
			"Weird background on share screen",
			"Out of dresscode"
		];

	}

	// ---
	// PUBLIC METHODS.
	// ---

	// I apply the new phrases from the edit-form.
	public applyNewPhrases( newPhrases: string[] ) : void {

		this.phrases = newPhrases;
		this.mode = "play";
		this.savePhrasesToUrl();

	}


	// I switch the user over to the given experience.
	public gotoMode( newMode: Mode ) : void {

		this.mode = newMode;

	}


	// I get called once after the inputs have been bound for the first time.
	public ngOnInit() : void {

		// If the URL has been copy-pasted to other individuals, the URL will contain the
		// phrase definitions for the current game. In such a situation, we have to use
		// the URL to override the current game state.
		// --
		// NOTE: We're not going to bother registering the "hashchange" event-listener
		// since it is NOT LIKELY that a user will manually change the hash - instead,
		// they are much more likely to just copy-paste a URL into a new browser-tab.
		this.applyHash( window.location.hash.slice( 1 ) );

	}


	// EXPERIMENTAL: I try to take a screenshot of the current bingo-board using the
	// html2canvas library.
	public takeScreenshot() : void {

		// The html2canvas library, at the time of this writing, is having trouble
		// generating canvas images if the window is scrolled down. To "fix" this, we
		// need to scroll the user back to the top before we initiate the screenshot.
		// --
		// Read more: https://github.com/niklasvh/html2canvas/issues/1878
		window.scrollTo( 0, 0 );

		var element = document.querySelector( "app-bingo-board" ) as HTMLElement;

		// Generate the screenshot using html2canvas.
		var promise = html2canvas(
			element,
			{
				logging: false,
				// CAUTION: These dimensions match the explicit height/width being
				// applied internally on the app-bingo-board component when the
				// html2canvas class is injected. Which means, these values have to be
				// kept in sync with another part of the code.
				width: 1200,
				height: 900,
				// The onclone callback gives us access to the cloned DOCUMENT before the
				// screenshot is generated. This gives us the ability to make edits to
				// the DOM that won't affect the original page content. In this case, I
				// am applying a special CSS class that allows me to set a fixed-size for
				// the bingo-board in order to get the screenshot to prevent clipping.
				onclone: ( doc ) => {

					doc.querySelector( "app-bingo-board" )!.classList.add( "html2canvas" );

				}
			}
		);

		promise
			.then(
				( canvas ) => {

					// Once the screenshot has been generated (as a canvas element), we
					// can grab the PNG data URI which we can then use to render an IMG
					// tag in the app.
					this.screenshotUrl = canvas.toDataURL();

					// Once the change-detection has had time to reconcile the View with
					// the View-model, our screenshot should be rendered on the page.
					// Let's try to scroll the user down to the IMG.
					setTimeout(
						() => {

							document.querySelector( ".screenshot" )!.scrollIntoView({
								block: "start",
								behavior: "smooth"
							});

						},
						100
					);

				}
			)
			.catch(
				( error ) => {

					console.warn( "An error occurred." );
					console.error( error );

				}
			)
		;

	}

	// ---
	// PRIVATE METHODS.
	// ---

	// I apply the given hash to the current game state.
	private applyHash( base64Value: string ) : void {

		// If the hash is empty, then update the hash to reflect the current state of
		// the bingo board. This way, the user will be setup to copy-paste the current
		// URL over to other participants.
		if ( ! base64Value ) {

			this.savePhrasesToUrl();
			return;

		}

		try {

			this.phrases = Utilities.base64UrlDecode( base64Value )
				.split( /&/g )
				.map(
					( rawPhrase ) => {

						return( decodeURIComponent( rawPhrase ) );

					}
				)
				.filter(
					( phrase ) => {

						return( !! phrase );

					}
				)
			;

		} catch ( error ) {

			console.group( "Error decoding URL" );
			console.error( error );
			console.groupEnd();

		}

	}


	// I update the URL hash to reflect the current phrases configuration. This allows
	// the bingo game to be copy-pasted to other participants.
	private savePhrasesToUrl() : void {

		var encodedPhrases = this.phrases
			.map(
				( phrase ) => {

					return( encodeURIComponent( phrase ) );

				}
			)
			.join( "&" )
		;

		window.location.hash = Utilities.base64UrlEncode( encodedPhrases );

	}

}

The view for the AppComponent is fairly small as well as it is primarily relies on rendering the two other Angular components that we've already looked at:

<!-- BEGIN: Play Mode. -->
<div *ngIf="( mode === 'play' )" class="content">

	<nav class="actions">
		<a (click)="gotoMode( 'edit' )">Edit phrases</a>
	</nav>

	<app-bingo-board
		[phrases]="phrases">
	</app-bingo-board>

	<p>
		Experimental:
		<a (click)="takeScreenshot()">Take screenshot of bingo board</a>
	</p>

	<p *ngIf="screenshotUrl" class="screenshot">
		<img [src]="screenshotUrl" />
	</p>

</div>
<!-- END: Play Mode. -->

<!-- BEGIN: Edit Mode. -->
<div *ngIf="( mode === 'edit' )" class="content">

	<nav class="actions">
		<a (click)="gotoMode( 'play' )">Back to game</a>
	</nav>

	<app-form
		[phrases]="phrases"
		(phrasesChange)="applyNewPhrases( $event )">
	</app-form>

</div>
<!-- END: Edit Mode. -->

And that's all there is to it. It's obviously not the most robust game - and it doesn't do anything like warn people if they are about to refresh their game accidentally. But, it was a fun little demo to put together. For me, there were a few critical take-aways:

  • Template-driven forms are very simple and very powerful and remove much of the complexity and ceremony of reactive forms.

  • Mutating data structures is perfectly safe - even optimal - when performed as an "implementation detail"; and, did not in any way affect our ability to adhere to a uni-directional, one-way data flow.

  • NgModel is one of the best things since sliced-bread - despite all the haters.

  • Storing state in the URL is an easy way to share state without having a server-side persistence mechanism (though it does, of course, generate rather large URLs).

Anyway, hopefully some part of this Angular 10.1.2 demo was interesting for you.



Reader Comments

What has two thumbs and hopes you leave a comment? This Guy! (Ben Nadel).

Post A Comment

You — Get Out Of My Dreams, Get Into My Blog
Live in the Now
Oops!
NEW: Some basic markdown formatting is now supported: bold, italic, blockquotes, lists, fenced code-blocks. Read more about markdown syntax »
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.