Talking about Wordle game, Game development, HTML5, Javascript, Phaser and TypeScript.
Here we go with the 4th part of the Wordle tutorial series.
In first step I showed you how to handle keyboard input and result management.
In second step we saw how to add a virtual keyboard.
In third step I added the game board and improved virtual keyboard layout.
Now it’s time to build a menu to select game modes and pick the word of the day, which must be the same for everyone playing on a given day.
How can we toss a random word which is “not that random”, since everyone can see the same word?
It’s just a matter of setting the seed of the random function.
As for the in game menu, we are going to use a Bootstrap dropdown imported as a DOM as explained in the post Add Bootstrap component to your HTML5 games powered by Phaser thanks to its DOM support and properly scaling the component.
Look at what we are going to build:
Use the keyboard to write the word. You can play with your physical keyboard, using Backspace to delete the last letter, Enter to submit the word or Space to restart the game with a new randomly picked word.
Also, the big menu allows you to play with the word of the day, with a random word or to visit my site. Statistic option still does not work and will be completed in next step.
You can see the word to guess in the console. If you are reading this post with a mobile device, you can play directly with the prototype at this link.
Let’s have a look at the assets used in the prototype:
bigfont.fnt and bigfont.png: respectively the XML information and the image of the big font.
bigkey.png: a sprite sheet containing Enter and Delete keys.
box.png: sprite sheet the box where to write the each letter of the word.
dropdown.html: the HTML page of the Bootstrap dropdown
font.fnt and font.png: respectively the XML information and the image of the small font.
key.png: sprite sheeet of the virtual keyboard key.
words.json: the list of allowed words.
And this is the completely commented source code of the prototype, split in two html files, one css file and nine TypeScript files:
index.html
The web page which hosts the game, to be run inside thegame element.
Here we also load Bootstrap and JQuery frameworks.
<!DOCTYPE html>
<html>
<head>
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha256-4+XzXVhsDmqanXGHaHvgh1gMQKX40OUvDEBTu8JcmNs=" crossorigin="anonymous"></script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-giJF6kkoqNQ00vy+HMDP7azOuL0xtbfIcaT9wjKHr8RbDVddVHyTfAAsrekwKmP1" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/js/bootstrap.bundle.min.js" integrity="sha384-ygbV9kiqUc6oa4msXn9868pTtWMgiQaeYH7/t7LECLbyPA2x65Kgf80OJFdroafW" crossorigin="anonymous"></script>
<link rel = 'stylesheet' href = 'style.css'>
<script src = 'main.js'></script>
</head>
<body>
<div id = 'thegame'></div>
</body>
</html>
dropdown.html
The HTML to draw the dropdown menu.
<button type="button" class="btn btn-dark btn-lg dropdown-toggle rounded-0" data-bs-toggle="dropdown">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="feather feather-menu"><line x1="3" y1="16" x2="29" y2="16"></line><line x1="3" y1="8" x2="29" y2="8"></line><line x1="3" y1="24" x2="29" y2="24"></line></svg>
</button>
<ul class="dropdown-menu dropdown-menu-dark rounded-0">
<li>
<a class="dropdown-item" href="#" id = "day">Word of the Day</a>
</li>
<li>
<a class="dropdown-item" href="#" id = "rnd">Random Word</a>
</li>
<li>
<a class="dropdown-item" href="#" id = "stats">Stats</a>
</li>
<li>
<hr class="dropdown-divider">
</li>
<li>
<a class="dropdown-item" href="https://emanueleferonato.com/">Developed by Emanuele Feronato</a>
</li>
</ul>
style.css
The style sheet of the main web page and a couple of Bootstrap class definitions to remove the default caret from dropdown and make the dropdown menu a little bigger.
* {
padding : 0;
margin : 0;
}
body{
background : #ffffff;
}
canvas {
touch-action : none;
-ms-touch-action : none;
}
/* remove caret from Bootstrap dropdown */
.dropdown-toggle:after {
content : none
}
/* make dropdown text a littler bigger */
.dropdown-menu {
font-size : 20px;
}
gameOptions.ts
Configurable game options.
// CONFIGURABLE GAME OPTIONS
export const GameOptions = {
// number of rows, that is the amount of tries to guess the word
rows : 6,
// vertical position of the first row, starting from the top of the screen
firstRowY : 150
}
main.ts
This is where the game is created, with all Phaser related options.
// MAIN GAME FILE
// modules to import
import Phaser from 'phaser';
import { PreloadAssets } from './preloadAssets';
import { PlayGame } from './playGame';
// object to initialize the Scale Manager
const scaleObject : Phaser.Types.Core.ScaleConfig = {
mode : Phaser.Scale.FIT,
autoCenter : Phaser.Scale.CENTER_BOTH,
parent : 'thegame',
width : 700,
height : 1244
}
// game configuration object
const configObject : Phaser.Types.Core.GameConfig = {
type : Phaser.AUTO,
backgroundColor : 0xffffff,
scale : scaleObject,
dom : {
createContainer : true
},
scene : [PreloadAssets, PlayGame]
}
// the game itself
new Phaser.Game(configObject);
preloadAssets.ts
Here we preload all assets to be used in the game, such as the JSON object with all words, the Bootstrap DOM element, the images and the bitmap fonts.
// CLASS TO PRELOAD ASSETS
// this class extends Scene class
export class PreloadAssets extends Phaser.Scene {
// constructor
constructor() {
super({
key : 'PreloadAssets'
});
}
// method to be execute during class preloading
preload(): void {
// this is how we preload a JSON object
this.load.json('words', 'assets/words.json');
// preload images
this.load.spritesheet('key', 'assets/key.png', {
frameWidth: 70,
frameHeight: 90
});
this.load.spritesheet('bigkey', 'assets/bigkey.png', {
frameWidth: 105,
frameHeight: 90
});
this.load.spritesheet('box', 'assets/box.png', {
frameWidth: 100,
frameHeight: 100
});
// this is how we preload a bitmap font
this.load.bitmapFont('font', 'assets/font.png', 'assets/font.fnt');
this.load.bitmapFont('bigfont', 'assets/bigfont.png', 'assets/bigfont.fnt');
// this is how we load a DOM element
this.load.html('dropdown', 'assets/dropdown.html');
}
// method to be called once the instance has been created
create(): void {
// call PlayGame class
this.scene.start('PlayGame');
}
}
playGame.ts
Main game file, where we handle input and result management, as well as DOM Game Object management.
// THE GAME ITSELF
import KeyboardKey from './keyboardKey';
import { GameOptions } from './gameOptions';
import { GameGrid } from './gameGrid';
// possible word states:
// perfect, when the letter is in the right position
// correct, when the letter exists but it's not in the right position
// wrong, when the letter does not exist
enum letterState {
WRONG = 1,
CORRECT,
PERFECT
}
// possible game modes
// random word: a word randomly picked from words array
// word of the day: a word picked according to current date
enum gameMode {
RANDOM_WORD,
WORD_OF_THE_DAY
}
// keyboard layout, as a string array, each item is a row of keys
// > represents Enter
// < represents Backspace
const keyboardLayout : string[] = ['QWERTYUIOP','ASDFGHJKL','>ZXCVBNM<'];
// this class extends Scene class
export class PlayGame extends Phaser.Scene {
// array with all possible words
words : string[];
// string where to store the current word
currentWord : string;
// string where to store the word to guess
wordToGuess : string;
// variable where to store game width
gameWidth : number;
// main game grid
gameGrid : GameGrid;
// virtual keyboard, as an array of KeyboardKey instances
virtualKeyboard : KeyboardKey[][];
// constructor
constructor() {
super({
key: 'PlayGame'
});
}
// method to be executed when the scene initialises
init(mode : any) : void {
// store JSON loaded words into words array
this.words = this.cache.json.get('words');
// different actions to do according to game mode
// I am using a "switch" rather than an "if...then" because I plan to add more game modes
switch (mode.game) {
// no game mode defined, or "word of the day" mode
case undefined :
case gameMode.WORD_OF_THE_DAY :
// set the random seed according to current date
let seed : string[] = Array(new Date().toISOString().split("T")[0]);
// set Phaser seed
Phaser.Math.RND.sow(seed);
// pick a random word, with the known seed
this.wordToGuess = this.words[Phaser.Math.RND.between(0, this.words.length - 1)].toUpperCase();
break;
// "random word" mode
case gameMode.RANDOM_WORD :
this.wordToGuess = Phaser.Utils.Array.GetRandom(this.words).toUpperCase();
}
}
// method to be executed when the scene has been created
create() : void {
// set gameWidth to actual game width
this.gameWidth = this.game.config.width as number;
// at the beginning, current word is empty
this.currentWord = '';
// let's display somewhere the word to guess
console.log(this.wordToGuess);
// initialize virtual keyboard
this.virtualKeyboard = [];
// loop through keyboardLayout array
keyboardLayout.forEach((row : string, index : number) => {
// initialize virtual keyboard row
this.virtualKeyboard[index] = [];
// determine position of key sprites
// some values are still hardcoded, and need to be optimized
let rowWidth : number = 70 * row.length;
let firstKeyPosition : number = (this.game.config.width as number - rowWidth) / 2;
// loop through string
for (let i : number = 0; i < row.length; i ++) {
// get the i-th character
let letter : string = row.charAt(i);
// add the keyboard key
this.virtualKeyboard[index][i] = new KeyboardKey(this, firstKeyPosition + i * 70 - (letter == '>' ? 35 : 0), 900 + index * 90, row.charAt(i));
}
});
// add the game grid
this.gameGrid = new GameGrid(this, GameOptions.rows, (this.gameWidth - 540) / 2, GameOptions.firstRowY);
// waiting for keyboard input
this.input.keyboard.on('keydown', this.onKeyDown, this);
// create a DOMElement game object
let dropdown : Phaser.GameObjects.DOMElement = this.add.dom(30, 30).createFromCache('dropdown');
// set its origin to top, left
dropdown.setOrigin(0);
// ajust DOMElement ratio according to game height / window height ratio
let ratio : number = this.game.config.height as number / window.innerHeight;
dropdown.setScale(ratio);
// add a click/tap listener to DOM element
dropdown.addListener('click');
// when the player clicks/taps on dropdown, call handleDropdownClick method
dropdown.on('click', this.handleDropdownClick, this);
}
// method to be called when the player clicks/taps on the dropdown
handleDropdownClick(event : any) : void {
// different operations according to id property of the dropdown menu
switch (event.target.id) {
// word of the day
case 'day' :
// this is how we pass an object to a scene, to be processed by init method
this.scene.start('PlayGame', {
game : gameMode.WORD_OF_THE_DAY
});
break;
// random word
case 'rnd' :
// this is how we pass an object to a scene, to be processed by init method
this.scene.start('PlayGame', {
game : gameMode.RANDOM_WORD
});
break;
}
}
// method to process a key pressed
onKeyDown(e : KeyboardEvent) : void {
// store key pressed in key variable
var key : string = e.key;
// if the key is space, restart the game
if (key == ' ') {
this.scene.start('PlayGame', {
gameMode : 2
});
return;
}
// backspace
if (key == 'Backspace') {
this.updateWord('<');
return;
}
// regular expression saying "I want one letter"
const regex = /^[a-zA-Z]{1}$/;
// letter a-z or A-Z
if (regex.test(key)) {
this.updateWord(key);
return;
}
// enter
if (key == 'Enter') {
this.updateWord('>');
}
}
//method to be called each time we need to update a word
updateWord(s : string) : void {
switch(s) {
// backsace
case '<' :
// if the word has at least one character, remove the last character
if (this.currentWord.length > 0) {
// remove last current word character
this.currentWord = this.currentWord.slice(0, -1);
// call gameGrid's removeLetter method
this.gameGrid.removeLetter();
}
break;
// enter
case '>' :
// if the word is 5 characters long, proceed to verify the result
if (this.currentWord.length == 5) {
// if the word is a valid word, proceed to verify the result
if (this.words.includes(this.currentWord.toLowerCase())) {
// at the beginning we se the result as a series of wrong characters
let result : number[] = Array(5).fill(letterState.WRONG);
// creation of a temp string with the word to guess
let tempWord : string = this.wordToGuess;
// loop through all word characters
for (let i : number = 0; i < 5; i ++) {
// do i-th char of the current word and i-th car of the word to guess match?
if (this.currentWord.charAt(i) == tempWord.charAt(i)) {
// this is a PERFECT result
result[i] = letterState.PERFECT;
// remove the i-th character from temp word
tempWord = this.removeChar(tempWord, i);
}
// i-th char of the current word and i-th car of the word to guess do not match
else {
// loop through all character of the word to guess
for (let j : number = 0; j < 5; j ++) {
// do i-th char of the current word and j-th car of the word to guess match,
// and don't j-th char of the current word and j-th car of the word to guess match?
if (this.currentWord.charAt(i) == this.wordToGuess.charAt(j) && this.currentWord.charAt(j) != this.wordToGuess.charAt(j)) {
// this is a correct result
result[i] = letterState.CORRECT;
// remove the i-th character from temp word
tempWord = this.removeChar(tempWord, j);
break;
}
}
}
}
// loop through all result items and compose result string accordingly
result.forEach((element : number, index : number) => {
// get letter position in our virtual keyboard
let position : Phaser.Math.Vector2 = this.getLetterPosition(this.currentWord.charAt(index));
// if the key of the virtual keyboard has not already been painted, then paint it.
if (parseInt(this.virtualKeyboard[position.x][position.y].frame.name) < element) {
this.virtualKeyboard[position.x][position.y].setFrame(element);
}
});
// reset current word
this.currentWord = '';
// call gameGrid's showResult method
this.gameGrid.showResult(result);
}
}
break;
// a-z or A-Z
default :
// if the word is less than 5 characters long, remove last character
if (this.currentWord.length < 5) {
// add the letter
this.gameGrid.addLetter(s);
// update current word
this.currentWord += s.toUpperCase();
}
}
}
// method to get the position the virtual keyboard key, given a letter
getLetterPosition(letter : string) : Phaser.Math.Vector2 {
// set row to zero
let row : number = 0;
// set column to zero
let column : number = 0;
// loop though all keyboardLayout array
keyboardLayout.forEach((currentRow : string, index : number) => {
// does current row include the letter?
if (currentRow.includes(letter)) {
// set row to index
row = index;
// set column to letter position inside current row
column = currentRow.indexOf(letter);
}
})
// return the coordinate as a 2D vector
return new Phaser.Math.Vector2(row, column);
}
// simple method to change the index-th character of a string with '_'
// just to have an unmatchable character
removeChar(initialString : string, index : number) : string {
return initialString.substring(0, index) + '_' + initialString.substring(index + 1);
}
}
keyboardKey.ts
Custom class for the virtual keyboard key.
// KEYBOARD KEY CLASS
import { PlayGame } from "./playGame";
import KeyboardLetter from "./keyboardLetter";
// this class extends Sprite class
export default class KeyboardKey extends Phaser.GameObjects.Sprite {
// letter bound to the key
boundLetter : string;
// parent scene
parentScene : PlayGame;
constructor(scene : PlayGame, x : number, y : number, letter : string) {
// different image key according if it's a letter character or '<' or '>'
super(scene, x, y, '<>'.includes(letter) ? 'bigkey' : 'key');
// assign parent scene
this.parentScene = scene;
// assign bound letter
this.boundLetter = letter;
// set sprite registration point to top, left
this.setOrigin(0);
// add the sprite to the scene
scene.add.existing(this);
// set the sprite interactive
this.setInteractive();
// listener for pointer down on the sprite, to call handlePointer callback
this.on('pointerdown', this.handlePointer);
// add a keyboard letter accoring to 'letter value
switch(letter) {
// backspace
case '<' :
this.setFrame(0);
break;
// enter
case '>' :
this.setFrame(1);
break;
// normal key
default :
new KeyboardLetter(scene, x + 10, y + 10, letter, 36);
}
}
// method to be called when the user clicks or taps the letter
handlePointer() : void {
// call 'updateWord' method on parent scene
this.parentScene.updateWord(this.boundLetter);
}
}
keyboardLetter.ts
Custom class for the letter to be printed on the virtual keyboard key.
// KEYBOARD LETTER
import { PlayGame } from "./playGame";
// this class extends BitmapText class
export default class KeyboardLetter extends Phaser.GameObjects.BitmapText {
constructor(scene : PlayGame, x : number, y : number, text : string, size : number) {
super(scene, x, y, 'font', text, size);
// add the keyboard letter to the scene
scene.add.existing(this);
}
}
gameGrid.ts
This class manages the grid where players write and try to guess the word.
// GAME GRID
import BigLetterBox from './bigLetterBox';
export class GameGrid {
// current row where to write the letter
currentRow : number;
// current column where to write the letter
currentColumn : number;
// array to store all letter boxes
letterBox : BigLetterBox[][];
constructor(scene : Phaser.Scene, rows : number, firstRowX : number, firstRowY : number) {
// set current row to zero
this.currentRow = 0;
// set current column to zero
this.currentColumn = 0;
// initialize letterBox array
this.letterBox = [];
// loop from 0 to 4
for (let i: number = 0; i < 5; i ++) {
// initialize letterBox[i] array
this.letterBox[i] = [];
// loop through all rows
for (let j : number = 0; j < rows; j ++) {
// assign to letterBox[i][j] a new BigLetterBox instance
this.letterBox[i][j] = new BigLetterBox(scene, firstRowX + i * 110, firstRowY + j * 110);
}
}
}
// method to add a latter
addLetter(letter : string) : void {
// set the letter at current row and column
this.letterBox[this.currentColumn][this.currentRow].setLetter(letter);
// increase current column
this.currentColumn ++;
}
// method to remove a letter
removeLetter() : void {
// decrease current column
this.currentColumn --;
// unset the letter ant current row and column
this.letterBox[this.currentColumn][this.currentRow].setLetter('');
}
// show guess result
showResult(result : number[]) : void {
// loop through all result items
result.forEach((element : number, index : number) => {
// set letterBox frame according to element value
this.letterBox[index][this.currentRow].setFrame(element);
// paint the letter white
this.letterBox[index][this.currentRow].letterToShow.setTint(0xffffff);
});
// increase current row
this.currentRow ++;
// set current column to zero
this.currentColumn = 0;
}
}
bigLetterBox.ts
Class to mange the big letter box inside game grid.
// BIG LETTER BOX
import BigLetter from "./bigLetter";
// this class extends Sprite class
export default class BigLetterBox extends Phaser.GameObjects.Sprite {
// letter to show in the big letter box
letterToShow : BigLetter;
constructor(scene : Phaser.Scene, x : number, y : number) {
super(scene, x, y, 'box');
// set registration point to top left corner
this.setOrigin(0);
// add the letter box to the scene
scene.add.existing(this);
// calculate letter box bounds
let bounds : Phaser.Geom.Rectangle = this.getBounds();
// assign letterToShow an instance of BigLetter
this.letterToShow = new BigLetter(scene, bounds.centerX, bounds.centerY + 8);
}
// method to set the letter
setLetter(letter : string) : void {
// tint letterToShow black
this.letterToShow.setTint(0);
// set letterToShow text according to "letter" argument
this.letterToShow.setText(letter.toUpperCase());
}
}
bigLetter.ts
Custom class to manage the big letter which appears in the big letter box.
// BIG LETTER
// this class extends BitmapText class
export default class BigLetter extends Phaser.GameObjects.BitmapText {
constructor(scene : Phaser.Scene, x : number, y : number) {
super(scene, x, y, 'bigfont', '');
// set registration point to center
this.setOrigin(0.5);
// add the letter to the scene
scene.add.existing(this);
}
}
And now we have a working menu and the word of the day feature. In next step we’ll complete the game, meanwhile download the source code of the entire project.
Never miss an update! Subscribe, and I will bother you by email only when a new game or full source code comes out.