Skip to main content
Ben Nadel at cf.Objective() 2012 (Minneapolis, MN) with: Ed Northby
Ben Nadel at cf.Objective() 2012 (Minneapolis, MN) with: Ed Northby

Attempted Regular Expression Pattern Search Game For RegEx Day 2017 Using Angular 4.1.3

By on

As you know, June 1st was the tenth annual Regular Expression Day! This year, I was trying to build some sort of interactive game for the RegEx Day participants. But, unfortunately, the game proved too challenging to finish. I was able to get it to a point where it "mostly" works. But, it has visual edge-cases that make the state of the board ambiguous. That said, I'd like to share what I was able to put together.

Play the Pattern Search game on GitHub.

View the GitHub repository.

The game I was trying to build was a twist on the old "Word Search" game. Only, for Regular Expression Day, you wouldn't just be searching for words - you'd be searching for words that matched Regular Expression patterns. Ultimately, the game was just a word search under the hood; but, the list provided to the user was going to consist of patterns that were known to match words contained in the board.

The word search functionality itself was powered by an npm module by James Clark. I then took the npm module, generated several games (for randomization), and then tried to wrap it up in an Angular (v4.1.3) application.

The Angular application consists of two components: the App component and the Grid component. In the spirit of one-way data flow, I didn't want the Grid component to have "state". Instead, I designed it to accept a two-dimensional array of Letters and an array of "Grid Selections". As the user interacted with the grid, selecting letters, the Grid would emit a "selection" event; and, it was up to the App component to determine if the selection should be kept (and piped back into the Grid) or discarded.

I ended up wrapping the "grid selection" data into its own class, GridSelection, so that I could more easily compare two different selections as well as determine if an arbitrary grid location was part of an existing selection. As such, the grid component is a funky combination of both component and class:

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

interface GridLocation {
	row: number;
	column: number;
}

export interface GridSelectionEvent {
	letters: string[];
	selection: GridSelection;
}

export interface GridSelectionMapFunction {
	( row: number, column: number ) : any;
}

@Component({
	selector: "re-grid",
	inputs: [ "letters", "selections" ],
	outputs: [ "selectionEvent: selection" ],
	host: {
		"(document: mouseup)": "endSelection()"
	},
	changeDetection: ChangeDetectionStrategy.OnPush,
	styleUrls: [ "./grid.component.css" ],
	templateUrl: "./grid.component.htm"
})
export class GridComponent {

	public letters: string[][];
	public selectionEvent: EventEmitter<GridSelectionEvent>;
	public selections: GridSelection[];

	private pendingSelection: GridSelection;


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

		this.letters = [];
		this.selections = [];
		this.selectionEvent = new EventEmitter();
		this.pendingSelection = null;

	}


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


	// I handle the end of the selection gesture, possibly emitting a new selection if
	// the current selection does not conflict with selections that have already been
	// placed on the grid.
	public endSelection() : void {

		if ( ! this.pendingSelection ) {

			return;

		}

		// Check to see if the current selection is wholly contained (ie, subsumed) by
		// any of the existing selections.
		var isSubsumed = this.selections.some(
			( selection: GridSelection ) : boolean => {

				return( this.pendingSelection.isSubsumedBy( selection ) );

			}
		);

		// Only emit a selection event if the selection is new.
		if ( ! isSubsumed ) {

			var selectedLetters = this.pendingSelection.map<string>(
				( row: number, column: number ) : string => {

					return( this.letters[ row ][ column ] );

				}
			);

			this.selectionEvent.emit({
				letters: selectedLetters,
				selection: this.pendingSelection
			});

		}

		this.pendingSelection = null;

	}


	// I check to see if the given grid coordinates are part of a pending selection.
	public isPending( row: number, column: number ) : boolean {

		if ( ! this.pendingSelection ) {

			return( false );

		}

		return( this.pendingSelection.includes({ row, column }) );

	}


	// I check to see if the given grid coordinates are part of an existing selection.
	public isSelected( row: number, column: number ) : boolean {

		var result = this.selections.some(
			( selection: GridSelection ) : boolean => {

				return( selection.includes({ row, column }) );

			}
		);

		return( result );

	}


	// I start a new pending selection on the grid.
	public startSelection( row: number, column: number ) : void {

		this.pendingSelection = new GridSelection({ row, column });

	}


	// I update the pending selection using the given grid coordinates.
	public updateSelection( row: number, column: number ) : void {

		if ( ! this.pendingSelection ) {

			return;

		}

		this.pendingSelection.update({ row, column });

	}

}


export class GridSelection {

	private from: GridLocation;
	private to: GridLocation;

	// I initialize the grid location with the given starting location.
	constructor( start: GridLocation ) {

		this.setFrom( start );

	}


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


	// I check to see if the given grid location is contained within the selection.
	public includes( location: GridLocation ) : boolean {

		var isFound = this.gatherLocations().some(
			( selectionLocation: GridLocation ) : boolean => {

				return(
					( location.row === selectionLocation.row ) &&
					( location.column === selectionLocation.column )
				);

			}
		);

		return( isFound );

	}


	// I check to see if the current selection completely subsumes the given selection.
	public isSubsumedBy( selection: GridSelection ) : boolean {

		var isConflict = this.gatherLocations().every(
			( location: GridLocation ) : boolean => {

				return( selection.includes( location ) );

			}
		);

		return( isConflict );

	}


	// I map the selected grid location using the given callback / operator.
	public map<T>( callback: GridSelectionMapFunction ) : T[] {

		var result = this.gatherLocations().map(
			( location: GridLocation ) : T => {

				return( callback( location.row, location.column ) );

			}
		);

		return( result );

	}


	// I update the selection using the (TO) grid location.
	// --
	// CAUTION: This uses a very strict diagonal selection since using a fuzzy diagonal
	// runs the risk of moving off the grid and the selection is not aware of the grid
	// dimensions. We could probably rework the selection to either know about the gird;
	// or, move the selection logic into the grid. But, ... meh.
	public update( newTo: GridLocation ) : void {

		var deltaRow = Math.abs( newTo.row - this.from.row );
		var deltaColumn = Math.abs( newTo.column - this.from.column );
		var maxDelta = Math.max( deltaRow, deltaColumn );

		// Use the diagonal selection.
		if ( deltaRow === deltaColumn ) {

			this.setTo( newTo );

		// Force to be vertical selection.
		} else if ( deltaRow > deltaColumn ) {

			this.setTo({
				row: newTo.row,
				column: this.from.column
			});

		// Force to be horizontal selection.
		} else {

			this.setTo({
				row: this.from.row,
				column: newTo.column
			});

		}

	}


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


	// I gather all the concrete grid locations between the FROM and TO locations.
	private gatherLocations() : GridLocation[] {

		var count = Math.max(
			( Math.abs( this.to.row - this.from.row ) + 1 ),
			( Math.abs( this.to.column - this.from.column ) + 1 )
		);

		var rowIncrement = this.getIncrement( this.from.row, this.to.row );
		var columnIncrement = this.getIncrement( this.from.column, this.to.column );
		var iRow = this.from.row;
		var iColumn = this.from.column;

		var locations = [];

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

			locations.push({
				row: iRow,
				column: iColumn
			});

			iRow += rowIncrement;
			iColumn += columnIncrement;

		}

		return( locations );

	}


	// I get the increment [-1, 0, 1] that can be used to loop over the given range.
	private getIncrement( fromValue: number, toValue: number ) : number {

		if ( fromValue < toValue ) {

			return( 1 );

		} else if ( fromValue > toValue ) {

			return( -1 );

		} else {

			return( 0 );

		}

	}


	// I set the starting location of the selection.
	private setFrom( from: GridLocation ) : void {

		this.from = this.to = Object.assign( {}, from );

	}


	// I set the ending location of the selection.
	private setTo( to: GridLocation ) : void {

		if (
			// Not horizontal.
			( this.from.row !== to.row ) &&

			// Not vertical.
			( this.from.column !== to.column ) &&

			// Not diagonal.
			( Math.abs( to.row - this.from.row ) !== Math.abs( to.column - this.from.column ) )
			) {

			throw( new Error( "InvalidSelection" ) );

		}

		this.to = Object.assign( {}, to );

	}

}

Luckily, once the complicated logic was encapsulated in the grid interactions, the App component itself was fairly simple. All the App component does is listen for selection events and then to check see if the selection matches any of the given Regular Expression patterns.

NOTE: The App component doesn't actually look at the patterns (although it did originally); the App component compares the selections to words that are known to match the patterns. As such, it's not truly a pattern-matching game, though it could easily be changed to match patterns instead.

The game boards aren't generated in the Angular app. Instead, I generated them offline and then hard-coded three different options into the App component:

// Import the core angular services.
import { Component } from "@angular/core";
import _ = require( "lodash" );

// Import the application services.
import { GridSelection } from "./grid.component";
import { GridSelectionEvent } from "./grid.component";

interface Game {
	letters: string[][];
	words: string[];
	patterns: RegExp[];
}

interface GameItem {
	word: string;
	pattern: RegExp;
	selection: GridSelection;
}

@Component({
	selector: "my-app",
	styleUrls: [ "./app.component.css" ],
	template:
	`
		<re-grid
			[letters]="letters"
			[selections]="selections"
			(selection)="handleSelection( $event )">
		</re-grid>

		<ul>
			<li *ngFor="let item of items" [class.found]="item.selection">

				<strong>{{ item.pattern.source }}</strong>

				<span *ngIf="item.selection" (click)="removeSelection( item )" class="remove">
					Remove
				</span>

			</li>
		</ul>
	`
})
export class AppComponent {

	public items: GameItem[];
	public letters: string[][];
	public selections: GridSelection[];


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

		var game = this.getGame();

		this.letters = game.letters;
		this.selections = [];

		this.items = game.words.map(
			function( word: string, i: number ) : GameItem {

				return({
					word: word,
					pattern: game.patterns[ i ],
					selection: null
				});

			}
		);

	}


	// I check to see if game has been won. And, if so, alerts the user.
	public checkStatus() : void {

		// The game is considered complete / won if every item is associated with a
		// selection on the grid.
		var isWinner = this.items.every(
			( item: GameItem ) : boolean => {

				return( !! item.selection );

			}
		);

		if ( isWinner ) {

			setTimeout(
				function() {

					alert( "Noice! Way to RegExp like a boss!" );

				},
				500
			);

		}

	}


	// I handle the selection event from the grid.
	public handleSelection( event: GridSelectionEvent ) : void {

		// Since words may be placed on the grid in any direction, we have to check
		// the given selection using both the forwards and reversed letters.
		var selectedLetters = event.letters.join( "" ).toLowerCase();
		var selectedLettersInverse = event.letters.reverse().join( "" ).toLowerCase(); // CAUTION: In-place reverse.

		// Check the selection against the game configuration.
		for ( var item of this.items ) {

			if ( item.selection ) {

				continue;

			}

			if (
				( item.word === selectedLetters ) ||
				( item.word === selectedLettersInverse )
				) {

				this.selections.push( item.selection = event.selection );
				this.checkStatus();
				return;

			}

		}

	}


	// I remove the selection associated with the given item.
	public removeSelection( item: GameItem ) : void {

		this.selections = _.without( this.selections, item.selection );
		item.selection = null;

	}


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


	// I return a random game configuration.
	private getGame() : Game {

		// The various board configurations have been generated using the following list
		// of words. And, we know that these words map to specific Regular Expression
		// patterns (which is what we'll display to the user).
		var patternsMap = {
			"programmer": /program+er/,
			"javascript": /.{4}script/,
			"oop": /..p/,
			"function": /f.{4}ion/,
			"closure": /clos.*?e/,
			"ecmascript": /emca.+?t/,
			"noop": /n(.)\1p/,
			"array": /(a)(r)\2\1y/,
			"lexical": /(.)exica\1/,
			"prototype": /pr(ot)+?ype/,
			"constructor": /con.{5}tor/,
			"boolean": /.oo.ean/,
			"truthy": /...thy/,
			"falsey": /false[aeiouy]/,
			"comment": /co(.)\1ent/,
			"variable": /var.{5}/,
			"method": /.etho./
		};

		var configurations = [
			{
				letters: [
					"XVFUNCTION".split( "" ),
					"TPIRCSAVAJ".split( "" ),
					"IYPLLXQYYC".split( "" ),
					"LAOHORKEHO".split( "" ),
					"AROHSMUSTM".split( "" ),
					"CRNMUQYLUM".split( "" ),
					"IAOORFEARE".split( "" ),
					"XSOYEYKFTN".split( "" ),
					"EKPDOHTEMT".split( "" ),
					"LEPYTOTORP".split( "" )
				],
				words: [ "array", "closure", "comment", "falsey", "function", "javascript", "lexical", "method", "noop", "oop", "prototype", "truthy" ]
			},
			{
				letters: [
					"DTRNAELOOB".split( "" ),
					"DPEHOYTYRZ".split( "" ),
					"OIMCVNREPP".split( "" ),
					"HRMEAOUSDO".split( "" ),
					"TCARRITLTO".split( "" ),
					"ESRUITHANN".split( "" ),
					"MAGSACYFNS".split( "" ),
					"YVOOBNGHPF".split( "" ),
					"ZARLLUZOAE".split( "" ),
					"UJPCEFOFWP".split( "" )
				],
				words: [ "array", "boolean", "closure", "falsey", "function", "javascript", "method", "noop", "oop", "programmer", "truthy", "variable" ]
			},
			{
				letters: [
					"EEFUNCTION".split( "" ),
					"BLUSMETHOD".split( "" ),
					"HIBNAELOOB".split( "" ),
					"SWPAIAAPEY".split( "" ),
					"XLEXICALRH".split( "" ),
					"ARRAYRYXUT".split( "" ),
					"PJYESLAFSU".split( "" ),
					"WSGPOONVOR".split( "" ),
					"YGPMSPOOLT".split( "" ),
					"ZJTNEMMOCA".split( "" )
				],
				words: [ "array", "boolean", "closure", "comment", "falsey", "function", "lexical", "method", "noop", "oop", "truthy", "variable" ]
			}
		];

		var selectedConfig = this.getRandom( configurations );

		return({
			letters: selectedConfig.letters,
			words: selectedConfig.words,

			// Once we've selected the random game configuration, we have to generate the
			// patterns collection based on the words collection. After all, we want the
			// users to have to work backwards a bit (from pattern to word to selection).
			patterns: selectedConfig.words.map(
				( word: string ) : RegExp => {

					return( patternsMap[ word ] );

				}
			)
		});

	}


	// I get a random item from the given collection.
	private getRandom<T>( collection: T[] ) : T {

		var randomIndex = _.random( collection.length - 1 );

		return( collection[ randomIndex ] );

	}

}

All in all, when we run this Angular application and try to match a few Regular Expression patterns, we get the following output:

RegEx Day 2017 game - pattern search in Angular 4.1.3.

NOTE: The red denotes existing selections. The yellow denotes a pending selection (I took the screenshot while making a selection).

It's not a perfect implementation by any means; but, it was fun to get back into Angular after doing so much Node.js lately. It's crazy how much stuff starts to disappear from your mental model if you don't practice it consistently.

Want to use code from this post? Check out the license.

Reader Comments

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel