Creating the Memory Game

Fri May 02 2025 15:06:07 GMT+0100 (British Summer Time) canvasgamedevtutorialjavascripthtmlarchived

Memory game mechanics

Do you remember the Memory game? It's a simple game that is easy with small grid sizes, but increases substantially in difficulty with larger grids. If your short term memory is anything like mine, it becomes a click fest as opposed to any feat of skill... but let's remind ourselves of how the game works:

  1. A grid of hidden squares is laid out. The grid is filled with randomly placed (currently hidden) images, each of which has a matching partner elsewhere on the board.
  2. The player selects a place on the grid, and the image is made visible.
  3. The player selects another place on the grid, and if the images match both are left permanently revealed. If not, after a short delay both are hidden again.
  4. Steps 2 & 3 are repeated until the whole grid is uncovered. Scoring is done by time taken or number of failed attempts to match a pair.
Memory Game example

Really simple, right? Let's look at how our pseudocode will make this work. We begin by creating our grid:

[grid] = Grid of tiles size = (width x height)

while([grid] is not full):
    insert [image] into random free place in [grid]
    insert [matching image] into random free place in [grid]
    set [image], [matching image] to hidden

The product of width x height must be even! 7x7 will not work - 6x7 is fine!

Next, we'll do a bit of processing for the game loop itself:

[previous image] = nothing

while(all [images] in [grid] are not visible):

    if([click] on [hidden image]]):
        make [hidden image] visible
        
        if([previous image] == nothing):
            [previous image] = [hidden image]
            
        else if([previous image] matches [hidden image]):
            mark [previous image], [hidden image] visible
            
        else:
            (short delay): hide [previous image], [hidden image]
            [previous image] = nothing

There's a bit more to it than that, but this should give you a good idea. Continue on to see how we'll build this game in Javascript...

HTML Document

We'll start by creating a new HTML document - I called mine memory.htm, because that made it harder to forget what I was doing (I'll stop, I promise). The code for the document is very simple, and just contains a reference to an external Javascript file (memory-game.js in this example) and a Canvas element with and width of 300px and a height of 400px, and the ID game:

<!DOCTYPE html>
<html>
<head>

<script type="text/javascript" src="memory-game.js"></script>

</head>
<body>

<canvas id="game" width="300" height="400"></canvas>

</body>
</html>

Global variables

We'll assume you have a reasonable understanding of Javascript / OOP for this tutorial!

Now, in our Javascript file we'll create (memory-game.js), we'll start by creating some global variables to provide references and keep track of the game state. First off, drawing context and sprites:

var ctx = null;

var sprites = null, spritesLoaded = false;

Our ctx variable will store a reference to the 2D drawing context of our Canvas element, sprites will store the image which contains our game sprites, and spritesLoaded is a boolean flag that states whether or not our sprite image has been loaded yet.

var gameTime = 0, lastFrameTime = 0;
var currentSecond = 0, frameCount = 0, framesLastSecond = 0;

gameTime is the time that has currently elapsed (from the time a new game is started) in milliseconds, and lastFrameTime is the time, in milliseconds, the games draw function was last called.

The other variables, currentSecond, frameCount and framesLastSecond are for keeping track of the games frame rate for information and debugging.

Our other time related variable, finishedTime, is the time (in milliseconds) it took to complete the last completed game:

var finishedTime = 0;

We also want a couple of positioning variables, offsetX and offsetY - we'll use these to store where we've calculated the grid should be drawn on the Canvas, calculated dynamically at the start of each game based on the grid dimensions, the image dimensions, and the Canvas width/height:

var offsetX = 0;
var offsetY = 100;

Now for our grid related globals:

var grid = [];
var visibleFace = null;
var activeFaces = [];

grid is the actual array that will store all of the faces we'll be using as our images for this game, and their current state (visible, invisible, etc). visibleFace is the current face that has been selected by the player, but that they have not yet selected another face to match it to. In our pseudocode example on the first page of this article, it is the [previous image] variable.

Finally, activeFaces stores any of the faces on the grid which are in some way animated; in our code, this will store faces that are waiting for a short timeout before being hidden, after the Player has tried to match them to a none matching partner.

Global objects and Settings

We'll also need some global objects. The first of these, gameState, we'll use to keep track of the current screen to display, the game difficulty mode, and a boolean flag that states whether or not we've achieved a new best score for the current difficulty.

var gameState = {
    screen    : 'menu',
    mode    : 'easy',
    newBest    : false
};

We also want to keep track of the mouseState; we'll record the x and y position of the cursor on the Canvas, and whether or not there has been a click on the Canvas that needs processing. If no mouse click has occured we'll just store null, otherwise we'll store an array with the [x, y] position of the event.

var mouseState = {
    x    : 0,
    y    : 0,
    click    : null
};

Sprites

Our spriteSheet object stores information about the image we'll be taking our game sprites from. We'll store the src, which is the path to the image we'll be using for our sprites, the sprite width and height (spriteW, spriteH respectively) in pixels, and the number of cols and rows (columns and rows) our sprites cover on the image.

var spriteSheet = {
    src        : "sprites.jpg",
    spriteW        : 28,
    spriteH        : 28,
    cols        : 10,
    rows        : 6
};

Difficulties

Finally, we'll create a map of difficulties of game play. For each difficulty we'll specify the name of the difficulty level, the bestTime the player has achieved this session, in milliseconds (which will be 0 for each entry until they've actually played!), the width and height of the grid for each difficulty (this is the number of rows and columns that'll be filled with image pairs), and finally menuBox, which simply has the value [0, 0], as this will be calculated when the menu screen is drawn, and is the hitbox of the difficulty on the menu screen.

var difficulties = {
    easy    : {
        name        : "Easy",
        bestTime    : 0,
        width        : 4,
        height        : 4,
        menuBox        : [0,0]
    },
    medium : {
        name        : "Medium",
        bestTime    : 0,
        width        : 6,
        height        : 6,
        menuBox        : [0,0]
    },
    hard    : {
        name        : "Hard",
        bestTime    : 0,
        width        : 8,
        height        : 7,
        menuBox        : [0,0]
    }
};

An important note here: besides the fact the product of width x height must be even, the product, divided by 2 ((width x height) / 2) must be less than or equal to the number of available sprites (spriteSheet.cols x spriteSheet.rows). Otherwise, we don't have enough images to occupy the whole grid and the game cannot work!

The Face object

We'll use a Face class to keep track of each image on the grid. When we create a new Face, it'll be given the x, y position of the Face on the grid, and a spriteX, spriteY which is the column and row of the sprite to use for this entry:

function Face(x, y, spriteX, spriteY)
{

The spritePos is an array storing the calculated x, y pixel coordinates of the sprite for this face on the sprite image, calculated from spriteX x spriteSheet.tileW, and the spriteY x spriteSheet.tileH.

    this.spritePos = [(spriteX * spriteSheet.spriteW),
            (spriteY * spriteSheet.spriteH)];

We'll also store the calculated position of the Face relative to the grid top-left drawing position in pixels, using the x, y arguments multiplied by the spriteSheet.tileW and spriteSheet.tileH respectively:

    this.pos = [(x * spriteSheet.spriteW),
            (y * spriteSheet.spriteH)];

The typeID (index) of the sprite being used is calculated from the spriteY x spriteSheet.cols plus spriteX. This is used for comparing Faces to see if they match.

    this.typeID = (spriteY * spriteSheet.cols) + spriteX;

We'll also store the currentState of the Face, to begin with they'll all be hidden. The stateChanged will store the gameTime when the Face begins animating, but is set as 0 by default, and active is a boolean flag that states whether or not the Face is currently in the activeFaces list.

    this.currentState    = 'hidden';
    this.stateChanged    = 0;
    this.active        = false;
}

We can also close this function with our curly brace.

Face methods

Faces in the activeFaces list will have their update method called every time the game logic is processed:

Face.prototype.update = function()
{

If this Face has the state incorrect (the Player tried to match it to the wrong partner), we check if 700 milliseconds has elapsed since the stateChanged time was set...

    if(this.currentState=='incorrect' &&
        (gameTime - this.stateChanged) > 700)
    {

If it has, we hide this Face (change the currentState to hidden), change the stateChanged property to the current gameTime, and set the active flag to false (so we know to remove it from the activeFaces array):

        this.currentState    = 'hidden';
        this.stateChanged    = gameTime;
        this.active        = false;

We can close this if block and method.

    }
};

Handling input

When a Face is clicked on, we want to allow it to do some processing:

Face.prototype.click = function()
{

If the Face has already been matched to its partner, we do nothing; this entry no longer handles user input.

    if(this.currentState=='correct') { return; }

If the Face is the current visibleFace we'll just hide it - this is done by setting the currentState to hidden, the stateChanged time to 0, and the global visibleFace to null. We the return to exit the function:

    if(visibleFace==this)
    {
        this.currentState    = 'hidden';
        this.stateChanged    = 0;
        visibleFace        = null;
        return;
    }

If no Face is currently visible (visibleFace is null), then we want to make this the visibleFace. We change the Face currentState to visible, set the stateChanged to the current gameTime, and set the global visibleFace to this.

    if(visibleFace==null)
    {
        visibleFace        = this;
        this.currentState    = 'visible';
        this.stateChange     = gameTime;
    }

Otherwise, if the current visibleFace matches this Face (the typeID attributes match), we set the currentState to correct and update the stateChanged time...

    else if(visibleFace.typeID==this.typeID)
    {
        this.currentState    = 'correct';
        this.stateChanged    = gameTime;

...as well as updating the visibleFace currentState to correct and updating its stateChanged time, and after reset the visibleFace global to null:

        visibleFace.currentState    = 'correct';
        visibleFace.stateChanged    = gameTime;
        visibleFace            = null;

We'll close this else if statement, but before doing so call our method for checking if the grid is completely revealed (checkState).

        checkState();
    }

If the other if/else cases are not true, then we can assume the currently revealed faces do not match. In this case, we'll set the currentState of both this face and the visibleFace to incorrect, update their stateChanged time to the current gameTime, and set both of their active flags to true.

        this.currentState    = 'incorrect';
        this.stateChanged    = gameTime;
        this.active        = true;
        
        visibleFace.currentState    = 'incorrect';
        visibleFace.stateChanged    = gameTime;
        visibleFace.active        = true;

A reference to both this face and the visibleFace will be added to the visibleFaces array so we know we're updating them, and the global visibleFace reference will be reset to null:

        activeFaces.push(this);
        activeFaces.push(visibleFace);
        
        visibleFace = null;

We're then OK to close this if block and end the method:

    }
};

Game updates and logic

Every frame some logic processing needs to be done before we start drawing. We'll do this with a function called updateGame.

function updateGame()
{

We begin by checking which screen is being drawn. If the current screen is the menu then we only need to do processing if there's a new click event in the mouseState object:

    if(gameState.screen=='menu')
    {
        if(mouseState.click!=null)
        {

If there is, we loop through all of the difficulties, and see if the click y position falls between the first and second menuBox values for the difficulty. If it does, we start a new game at the selected difficulty level and break out of the loop.

            for(var d in difficulties)
            {
                if(mouseState.click[1]>=difficulties[d].menuBox[0] &&
                    mouseState.click[1]<=difficulties[d].menuBox[1])
                {
                    startLevel(d);
                    break;
                }
            }

After this check, we're done with processing for the menu screen, so we clear the mouseState.click event and exit out of this if block:

            mouseState.click = null;
        }
    }

Next, we handle processing for the won screen. This is even simpler - we just check for a click event. If one has occured, we just change the current screen to menu and clear the click event.

    else if(gameState.screen=='won')
    {
        if(mouseState.click!=null)
        {
            gameState.screen = 'menu';
            mouseState.click = null;
        }
    }

Finally, we do any processing for the playing screen if we're not currently on the menu or won screens. We begin by looping through all of the activeFaces and calling their update method.

    else
    {
        for(var x in activeFaces) { activeFaces[x].update(); }

We then filter the activeFaces array to remove any entries that are no longer flagged as active:

        activeFaces = activeFaces.filter(function(e) {
            return e.active;
        });

We now need to handle any click events:

        if(mouseState.click!=null)
        {

We begin by creating a shorthand reference, cDiff, to the current game difficulty level.

            var cDiff = difficulties[gameState.mode];

Next, we check if the click actually fell on the game grid. The x, y position of the event must be between the offsetX, offsetY positions and the grid width and height, in pixels, calculated by the number of columns / rows multiplied by the sprite width, height, added to the offsets:

            if(mouseState.click[0]>=offsetX &&
                mouseState.click[1]>=offsetY &&
                mouseState.click[0]<=(cDiff.width*spriteSheet.spriteW)+offsetX &&
                mouseState.click[1]<=(cDiff.height*spriteSheet.spriteH)+offsetY)
            {

If the event fell on the grid, we culculate the column and row it fell on by subtracting the offsets from the event coordinates, and dividing the x, y values by the sprite width and height properties (and then rounding down this result):

                var gridX = Math.floor((mouseState.click[0]-offsetX) /
                    spriteSheet.spriteW);
                var gridY = Math.floor((mouseState.click[1]-offsetY) /
                    spriteSheet.spriteH);

Now we can call the click method for the Face that was clicked by calculating its index in the grid from these values.

                grid[((gridY*cDiff.width)+gridX)].click();

We're done with the click handling for the grid, but we'll also use any click event at the bottom of the Canvas to allow the Player to exit the current level:

            }
            else if(mouseState.click[1] >= 380)
            {
                gameState.screen = 'menu';
            }

We can then clear the click event and leave this function.

        }
        mouseState.click = null;
    }
}

Checking for winning state

Whenever a pair of faces has been matched, we need to check if the level has been completed. We'll do this with the checkState function. This will store an allCorrect boolean flag, which starts with the value true.

function checkState()
{
    var allCorrect = true;

We can now loop over all of the faces in the grid. If we come across one that does not have the currentState correct, then the game isn't finished. We change the value of allCorrect to false and break out of this loop.

    for(var g in grid)
    {
        if(grid[g].currentState!='correct')
        {
            allCorrect = false;
            break;
        }
    }

If the level is completed, and allCorrect is still true after checking all the faces on the grid, we start by changing the current screen to the won screen.

    if(allCorrect)
    {
        gameState.screen = 'won';

If the time taken is less than the current difficulties bestTime, or the bestTime is 0 (this difficulty hasn't yet been won this session), then we have a new best time. Update the bestTime property and set the gameState.newBest flag to true:

        if(difficulties[gameState.mode].bestTime==0 ||
            gameTime < difficulties[gameState.mode].bestTime)
        {
            difficulties[gameState.mode].bestTime = gameTime;
            gameState.newBest = true;
        }

Finally, set the finishedTime global to the current gameTime and leave this if block and function:

        finishedTime = gameTime;
    }
}

Starting a new game

When the player starts a new game, we call the startLevel method with the key of the selected difficulty in the difficulties map (ie; easy, medium, or hard). We then begin by setting the globals gameTime and lastFrameTime to 0, and the gameState properties screen to playing, the newBest flag to false, and the mode to the function argument diff.

We also set the visibleFace global to null, as all faces are initially hidden.

function startLevel(diff)
{
    gameTime        = 0;
    lastFrameTime        = 0;
    gameState.screen    = 'playing';
    gameState.newBest    = false;
    gameState.mode        = diff;
    visibleFace        = null;

Also, we'll empty the global activeFaces and grid arrays:

    activeFaces.length    = 0;    
    grid.length         = 0;

The global offsetX, offsetY variables are calculated by subtracting the grid dimensions, in pixels (calculated from the grid width and height multiplied by the sprite width and height) from the Canvas element width and height properties, and dividing the results by 2.

    offsetX = Math.floor((document.getElementById('game').width -
            (difficulties[diff].width * spriteSheet.spriteW)) / 2);
    
    offsetY = Math.floor((document.getElementById('game').height -
            (difficulties[diff].height * spriteSheet.spriteH)) / 2);

We'll now create a new array, faceTypes, which we'll fill with an entry for each available sprite index.

    var faceTypes = [];
    for(var i = 0; i < (spriteSheet.cols * spriteSheet.rows); i++)
    {
        faceTypes.push(i);
    }

Also we need an array called gridPlaces; we'll create an index for each place we need to fill based on the current difficulties width x height, and fill the corresponding index in the grid global with a temporary null entry.

    var gridPlaces = [];
    for(var i = 0; i < (difficulties[diff].width *
        difficulties[diff].height); i++)
    {
        grid.push(null);
        gridPlaces.push(i);
    }

Now we add our pairs to the grid. We loop through the total number of grid index divided by 2:

    for(var i = 0; i < Math.floor(
        (difficulties[diff].width * difficulties[diff].height) / 2); i++)
    {

We now select a random face type from those available in the faceTypes array.

        var idxF = Math.floor(Math.random() * faceTypes.length);
        var idxFace = faceTypes[idxF];

We now insert 2 faces of this type into random places on the grid, by twice executing a piece of code that randomly selects an available space on the grid from the gridPlaces array:

        for(var f = 0; f < 2; f++)
        {
            var idx = Math.floor(Math.random() * gridPlaces.length);
            var idxPlace = gridPlaces[idx];

Into our grid array we insert a new Face object with the faceType we have chosen and the idxPlace that was randomly picked, converted into their x, y coordinates:

            grid[idxPlace] = new Face(
                idxPlace % difficulties[diff].width,
                Math.floor(idxPlace / difficulties[diff].width),
                idxFace % spriteSheet.cols,
                Math.floor(idxFace / spriteSheet.cols));

We now remove idxPlace from the gridPlaces array, as this index is no longer free, and exit the loop for placing the matching pair.

            gridPlaces.splice(idx, 1);
        }

Now we've placed the Face and its matching partner, we remove the idxFace from the faceTypes array and close the outer loop. We can also go ahead and close the function once this loop is completed.

        faceTypes.splice(idxF, 1);
    }
}

Window onload

When the window loads, we'll start by assigning ctx a reference to our Canvas elements 2D drawing content.

window.onload = function()
{
    ctx = document.getElementById('game').getContext('2d');

We'll also create event listeners for the click and mousemove events that update the mouseState object. The mouse position is converted using the realPos method to the cursor position relative to the top-left of the Canvas element:

    // Event listeners
    document.getElementById('game').addEventListener('click', function(e) {
        var pos = realPos(e.pageX, e.pageY);
        mouseState.click = pos;
    });
    document.getElementById('game').addEventListener('mousemove',
    function(e) {
        var pos = realPos(e.pageX, e.pageY);
        mouseState.x = pos[0];
        mouseState.y = pos[1];
    });

We also need to load our sprites image, from the path given in the spriteSheet.src property. If there is a problem loading, we'll set the ctx global back to null, which will stop further processing. Otherwise, when the loading is done we set the spritesLoaded variable to true so we know the game can begin.

    // Load our spritesheet
    sprites = new Image();
    sprites.onerror = function()
    {
        ctx = null;
        alert("Failed loading sprite sheet.");
    };
    sprites.onload = function()
    {
        console.log("Sprites loaded");
        spritesLoaded = true;
    };
    sprites.src = spriteSheet.src;

We now let the window know that when it's ready, we want to handle drawing with the drawGame function, and close our load method.

    requestAnimationFrame(drawGame);
};

Drawing the menu

We'll handle drawing of each screen with its own method. For the drawMenu method, we begin by setting the font style and colour to that which will be used to draw the name of each difficulties entry.

function drawMenu()
{
    ctx.textAlign = 'center';
    ctx.font = "bold 20pt sans-serif";
    ctx.fillStyle = "#000000";

We'll also store the vertical offset at which we'll begin drawing the menu difficulties in the y variable, and then loop through all available difficulties:

    var y = 100;
    
    for(var d in difficulties)
    {

We now determine if the mouse cursor is currently roughly hovering over this entry, and set the mouseOver boolean accordingly. We use the state of this variable to choose the colour for the difficulties name property.

        var mouseOver = (mouseState.y>=(y-20) && mouseState.y<=(y+10));
        
        if(mouseOver) { ctx.fillStyle = "#000099"; }

The menuBox property is updated to be roughly the vertical start and end dimensions of the current difficulty, and the text is drawn. After, the y variable is incremented by 80 ready to draw the next entry, and if the colour was changed due to the mouse being over this area, it is now changed back and the loop is closed.

        difficulties[d].menuBox = [y-20, y+10];
        ctx.fillText(difficulties[d].name, 150, y);
        y+= 80;
        
        if(mouseOver) { ctx.fillStyle = "#000000"; }
    }

Next, we set the y back, but add a little to place these lines of text below each difficulty name. We then change the font to that we wish to use for the sub-text (showing the best time) for each difficulty, and begin looping over the difficulties again:

    var y = 120;
    ctx.font = "italic 12pt sans-serif";
    for(var d in difficulties)
    {

If the difficulty has no bestTime yet (the value is still 0), we show this:

        if(difficulties[d].bestTime==0)
        {
            ctx.fillText("No best time", 150, y);
        }

Otherwise, we draw the current best time, formatted for the number of minutes, and the seconds to 2 decimal places:

        else
        {
            var t = difficulties[d].bestTime;
            var bestTime = "";
            if((t/1000)>=60)
            {
                bestTime = Math.floor((t/1000)/60) + ":";
                t = t % (60000);
            }
            bestTime+= Math.floor(t/1000) +
                "." + (t%1000);
            ctx.fillText("Best time   " + bestTime, 150, y);
        }

We now move the y down by 80 again, and close both the loop and the function.

        y+= 80;
    }
}

Drawing the game while playing

To draw our playing screen, whilst a game is in progress, we use the drawPlaying function. We begin by setting the font and drawing the current difficulty near the top of the screen.

function drawPlaying()
{
    ctx.textAlign = 'center';
    ctx.font = "bold 20pt sans-serif";
    ctx.fillStyle = "#000000";
    
    ctx.fillText(difficulties[gameState.mode].name, 150, 25);

We now change the font and draw the current number of minutes/seconds elapsed:

    ctx.textAlign = 'left';
    ctx.font = "italic 12pt sans-serif";
    var t = gameTime;
    var cTime = "Time ";
    if((t/1000)>=60)
    {
        cTime+= Math.floor((t/1000)/60) + ":";
        t = t % (60000);
    }
    cTime+= (Math.floor(t/1000) < 9 ? "0" : "") + Math.floor(t/1000);
    ctx.fillText(cTime, 100, 45);

We now change the drawing fill colour to a yellow we'll be using the show the "backs" of the faces. Then, we begin looping through all of the faces in the grid:

    ctx.fillStyle = "#dddd00";
    for(var g in grid)
    {

If the Face at the current grid index is marked as hidden in its currentState, we just draw a filled circle at this position.

        if(grid[g].currentState=='hidden')
        {
            ctx.beginPath();
            ctx.arc(offsetX+grid[g].pos[0] + (spriteSheet.spriteW/2),
                offsetY+grid[g].pos[1] + (spriteSheet.spriteH/2),
                Math.round(spriteSheet.spriteW/2), 0, Math.PI*2);
            ctx.closePath();
            ctx.fill();
        }

Otherwise, the Face is drawn from the sprites image to the Canvas:

        else
        {
            ctx.drawImage(sprites,
                grid[g].spritePos[0], grid[g].spritePos[1],
                spriteSheet.spriteW, spriteSheet.spriteH,
                offsetX+grid[g].pos[0], offsetY+grid[g].pos[1],
                spriteSheet.spriteW, spriteSheet.spriteH);
        }

That's it for the drawing loop - we can now close it, and set the font to draw the "Back to menu" link, before exiting the function.

    }
    
    ctx.fillStyle = "#000000";
    ctx.textAlign = 'right';
    ctx.fillText("<< Back to menu", 290, 390);
}

Drawing Won screen

If the player has just won a game, we'll show the winning screen with the drawWon method until the player clicks somewhere to return to the menu. We begin by setting the font and drawing the difficulties.name property of the current difficulty.

function drawWon()
{
    ctx.textAlign = 'center';
    ctx.font = "bold 20pt sans-serif";
    ctx.fillStyle = "#000000";
    
    ctx.fillText(difficulties[gameState.mode].name, 150, 100);

We then change the font as desired, and draw the time taken to complete the level, formatting for minutes and seconds.

    ctx.font = "italic 12pt sans-serif";
    var t = finishedTime;
    var cTime = "Completed in ";
    if((t/1000)>=60)
    {
        cTime+= Math.floor((t/1000)/60) + ":";
        t = t % (60000);
    }
    cTime+= (Math.floor(t/1000) < 9 ? "0" : "") + Math.floor(t/1000);
    ctx.fillText(cTime, 150, 120);

If the player has achieved a new best time, tell them so on this screen:

    if(gameState.newBest)
    {
        ctx.fillText("New best time!", 150, 140);
    }

Otherwise, show the current best time for this difficulty.

    else
    {
        t = difficulties[gameState.mode].bestTime;
        cTime = "Best time ";
        if((t/1000)>=60)
        {
            cTime+= Math.floor((t/1000)/60) + ":";
            t = t % (60000);
        }
        cTime+= (Math.floor(t/1000) < 9 ? "0" : "") +
            Math.floor(t/1000);
        ctx.fillText(cTime, 150, 140);
    }

Finally, draw text to prompt the player that they can click somewhere to return to the menu, and close the function.

    ctx.fillText("(Click to jump to menu)", 150, 200);
}

Main Game loop

Our drawGame method is called every time the window is ready for us to draw another frame to the Canvas. In this function we begin by checking the ctx (2D drawing context) is not null - if it does we leave the function and processing is finished. This happens if a problem has occured, such as if the sprites could not be loaded.

We also check if the sprites are loaded with spritesLoaded flag. If they are not, we tell the window to check back next time it's ready to do some drawing, and leave the function for now...

function drawGame()
{
    if(ctx==null) { return; }
    if(!spritesLoaded) { requestAnimationFrame(drawGame); return; }

We can now calculate the elapsed game time from the current time, in milliseconds, subtracting the time of the last frame. If the lastFrameTime is 0 (such as the first time this code is executed when a new game has begun), we immediately set it to the current time:

    var currentFrameTime = Date.now();
    if(lastFrameTime==0) { lastFrameTime = currentFrameTime; }
    var timeElapsed = currentFrameTime - lastFrameTime;
    gameTime+= timeElapsed;

Now we can call our updateGame method to process game logic. After, we update our frame counting (framerate) variables to keep track of the number of frames being drawn each second.

    updateGame();

    // Frame counting
    var sec =
Math.floor(Date.now()/1000);
    if(sec!=currentSecond)
    {
        currentSecond = sec;
        framesLastSecond = frameCount;
        frameCount = 1;
    }
    else { frameCount++; }

The Canvas is then cleared to plain white, and we call the draw method for the current screen.

    ctx.fillStyle = "#ddddee";
    ctx.fillRect(0, 0, 300, 400);
    
    // Draw the current screen
    if(gameState.screen=='won') { drawWon(); }
    else if(gameState.screen=='playing') { drawPlaying(); }
    else if(gameState.screen=='menu') { drawMenu(); }

We then set our font and draw the framerate.

    ctx.textAlign = "left";
    ctx.font = "10pt sans-serif";
    ctx.fillStyle = "#000000";
    ctx.fillText("Frames: " + framesLastSecond, 5, 15);

Finally, we update the lastFrameTime to the value of currentFrameTime, and let the window know we'll handle the drawing next time it's ready for us to do so, before closing the function.

    lastFrameTime = currentFrameTime;
    
    // Wait for the next frame...
    requestAnimationFrame(drawGame);
}

Click coordinates helper

We'll also make use of a helper function to convert the event listener mouse coordinates (which are relative to the top-left of the document), to the actual position on the Canvas element:

function realPos(x, y)
{
    var p = document.getElementById('game');
    
    do {
        x-= p.offsetLeft;
        y-= p.offsetTop;
        
        p = p.offsetParent;
    } while(p!=null);
    
    return [x, y];
}

Complete Javascript source code


var ctx = null;

var sprites = null, spritesLoaded = false;
var gameTime = 0, lastFrameTime = 0;
var currentSecond = 0, frameCount = 0, framesLastSecond = 0;

var finishedTime = 0;

var offsetX = 0;
var offsetY = 100;

var grid = [];
var visibleFace = null;
var activeFaces = [];

var gameState = {
    screen    : 'menu',
    mode    : 'easy',
    newBest    : false
};

var mouseState = {
    x    : 0,
    y    : 0,
    click    : null
};

var spriteSheet = {
    src        : "sprites.jpg",
    spriteW        : 28,
    spriteH        : 28,
    cols        : 10,
    rows        : 6
};

var difficulties = {
    easy    : {
        name        : "Easy",
        bestTime    : 0,
        width        : 4,
        height        : 4,
        menuBox        : [0,0]
    },
    medium : {
        name        : "Medium",
        bestTime    : 0,
        width        : 6,
        height        : 6,
        menuBox        : [0,0]
    },
    hard    : {
        name        : "Hard",
        bestTime    : 0,
        width        : 8,
        height        : 7,
        menuBox        : [0,0]
    }
};

function Face(x, y, spriteX, spriteY)
{
    this.spritePos = [(spriteX * spriteSheet.spriteW),
                        (spriteY * spriteSheet.spriteH)];
    this.pos = [(x * spriteSheet.spriteW),
                (y * spriteSheet.spriteH)];
    this.typeID = (spriteY * spriteSheet.cols) + spriteX;
    
    this.currentState    = 'hidden';
    this.stateChanged    = 0;
    this.active        = false;
}
Face.prototype.update = function()
{
    if(this.currentState=='incorrect' &&
        (gameTime - this.stateChanged) > 700)
    {
        this.currentState    = 'hidden';
        this.stateChanged    = gameTime;
        this.active        = false;
    }
};
Face.prototype.click = function()
{
    if(this.currentState=='correct') { return; }
    
    if(visibleFace==this)
    {
        this.currentState    = 'hidden';
        this.stateChanged    = 0;
        visibleFace        = null;
        return;
    }
    
    if(visibleFace==null)
    {
        visibleFace        = this;
        this.currentState    = 'visible';
        this.stateChange     = gameTime;
    }
    else if(visibleFace.typeID==this.typeID)
    {
        this.currentState    = 'correct';
        this.stateChanged    = gameTime;
        
        visibleFace.currentState    = 'correct';
        visibleFace.stateChanged    = gameTime;
        visibleFace            = null;
        
        checkState();
    }
    else
    {
        this.currentState    = 'incorrect';
        this.stateChanged    = gameTime;
        this.active        = true;
        
        visibleFace.currentState    = 'incorrect';
        visibleFace.stateChanged    = gameTime;
        visibleFace.active        = true;
        
        activeFaces.push(this);
        activeFaces.push(visibleFace);
        
        visibleFace = null;
    }
};

function updateGame()
{
    if(gameState.screen=='menu')
    {
        if(mouseState.click!=null)
        {
            for(var d in difficulties)
            {
                if(mouseState.click[1]>=difficulties[d].menuBox[0] &&
                    mouseState.click[1]<=difficulties[d].menuBox[1])
                {
                    startLevel(d);
                    break;
                }
            }
            mouseState.click = null;
        }
    }
    else if(gameState.screen=='won')
    {
        if(mouseState.click!=null)
        {
            gameState.screen = 'menu';
            mouseState.click = null;
        }
    }
    else
    {
        for(var x in activeFaces) { activeFaces[x].update(); }
        activeFaces = activeFaces.filter(function(e) {
            return e.active;
        });
        
        if(mouseState.click!=null)
        {
            var cDiff = difficulties[gameState.mode];
            
            if(mouseState.click[0]>=offsetX &&
                mouseState.click[1]>=offsetY &&
                mouseState.click[0]<=(cDiff.width*spriteSheet.spriteW)+offsetX &&
                mouseState.click[1]<=(cDiff.height*spriteSheet.spriteH)+offsetY)
            {
                var gridX = Math.floor((mouseState.click[0]-offsetX) /
                    spriteSheet.spriteW);
                var gridY = Math.floor((mouseState.click[1]-offsetY) /
                    spriteSheet.spriteH);
                
                grid[((gridY*cDiff.width)+gridX)].click();
            }
            else if(mouseState.click[1] >= 380)
            {
                gameState.screen = 'menu';
            }
        }
        mouseState.click = null;
    }
}

function checkState()
{
    var allCorrect = true;
    
    for(var g in grid)
    {
        if(grid[g].currentState!='correct')
        {
            allCorrect = false;
            break;
        }
    }
    
    if(allCorrect)
    {
        gameState.screen = 'won';
        
        if(difficulties[gameState.mode].bestTime==0 ||
            gameTime < difficulties[gameState.mode].bestTime)
        {
            difficulties[gameState.mode].bestTime = gameTime;
            gameState.newBest = true;
        }
        
        finishedTime = gameTime;
    }
}

function startLevel(diff)
{
    gameTime        = 0;
    lastFrameTime        = 0;
    gameState.screen    = 'playing';
    gameState.newBest    = false;
    gameState.mode        = diff;
    visibleFace            = null;

    activeFaces.length    = 0;    
    grid.length         = 0;
    
    offsetX = Math.floor((document.getElementById('game').width -
            (difficulties[diff].width * spriteSheet.spriteW)) / 2);
    
    offsetY = Math.floor((document.getElementById('game').height -
            (difficulties[diff].height * spriteSheet.spriteH)) / 2);
    
    var faceTypes = [];
    for(var i = 0; i < (spriteSheet.cols * spriteSheet.rows); i++)
    {
        faceTypes.push(i);
    }
    
    var gridPlaces = [];
    for(var i = 0; i < (difficulties[diff].width *
        difficulties[diff].height); i++)
    {
        grid.push(null);
        gridPlaces.push(i);
    }
        
    
    for(var i = 0; i < Math.floor(
        (difficulties[diff].width * difficulties[diff].height) / 2); i++)
    {
        var idxF = Math.floor(Math.random() * faceTypes.length);
        var idxFace = faceTypes[idxF];
        
        for(var f = 0; f < 2; f++)
        {
            var idx = Math.floor(Math.random() * gridPlaces.length);
            var idxPlace = gridPlaces[idx];
        
            grid[idxPlace] = new Face(
                idxPlace % difficulties[diff].width,
                Math.floor(idxPlace / difficulties[diff].width),
                idxFace % spriteSheet.cols,
                Math.floor(idxFace / spriteSheet.cols));
            
            gridPlaces.splice(idx, 1);
        }
        
        faceTypes.splice(idxF, 1);
    }
}

window.onload = function()
{
    ctx = document.getElementById('game').getContext('2d');

    // Event listeners
    document.getElementById('game').addEventListener('click', function(e) {
        var pos = realPos(e.pageX, e.pageY);
        mouseState.click = pos;
    });
    document.getElementById('game').addEventListener('mousemove',
    function(e) {
        var pos = realPos(e.pageX, e.pageY);
        mouseState.x = pos[0];
        mouseState.y = pos[1];
    });
    
    // Load our spritesheet
    sprites = new Image();
    sprites.onerror = function()
    {
        ctx = null;
        alert("Failed loading sprite sheet.");
    };
    sprites.onload = function()
    {
        console.log("Sprites loaded");
        spritesLoaded = true;
    };
    sprites.src = spriteSheet.src;
    
    requestAnimationFrame(drawGame);
};

function drawMenu()
{
    ctx.textAlign = 'center';
    ctx.font = "bold 20pt sans-serif";
    ctx.fillStyle = "#000000";
    
    var y = 100;
    
    for(var d in difficulties)
    {
        var mouseOver = (mouseState.y>=(y-20) && mouseState.y<=(y+10));
        
        if(mouseOver) { ctx.fillStyle = "#000099"; }
        
        difficulties[d].menuBox = [y-20, y+10];
        ctx.fillText(difficulties[d].name, 150, y);
        y+= 80;
        
        if(mouseOver) { ctx.fillStyle = "#000000"; }
    }
    
    var y = 120;
    ctx.font = "italic 12pt sans-serif";
    for(var d in difficulties)
    {
        if(difficulties[d].bestTime==0)
        {
            ctx.fillText("No best time", 150, y);
        }
        else
        {
            var t = difficulties[d].bestTime;
            var bestTime = "";
            if((t/1000)>=60)
            {
                bestTime = Math.floor((t/1000)/60) + ":";
                t = t % (60000);
            }
            bestTime+= Math.floor(t/1000) +
                "." + (t%1000);
            ctx.fillText("Best time   " + bestTime, 150, y);
        }
        y+= 80;
    }
}

function
drawPlaying()
{
    ctx.textAlign = 'center';
    ctx.font = "bold 20pt sans-serif";
    ctx.fillStyle = "#000000";
    
    ctx.fillText(difficulties[gameState.mode].name, 150, 25);
    
    ctx.textAlign = 'left';
    ctx.font = "italic 12pt sans-serif";
    var t = gameTime;
    var cTime = "Time ";
    if((t/1000)>=60)
    {
        cTime+= Math.floor((t/1000)/60) + ":";
        t = t % (60000);
    }
    cTime+= (Math.floor(t/1000) < 9 ? "0" : "") + Math.floor(t/1000);
    ctx.fillText(cTime, 100, 45);
    
    ctx.fillStyle = "#dddd00";
    
    for(var g in grid)
    {
        if(grid[g].currentState=='hidden')
        {
            ctx.beginPath();
            ctx.arc(offsetX+grid[g].pos[0] + (spriteSheet.spriteW/2),
                offsetY+grid[g].pos[1] + (spriteSheet.spriteH/2),
                Math.round(spriteSheet.spriteW/2), 0, Math.PI*2);
            ctx.closePath();
            ctx.fill();
        }
        else
        {
            ctx.drawImage(sprites,
                grid[g].spritePos[0], grid[g].spritePos[1],
                spriteSheet.spriteW, spriteSheet.spriteH,
                offsetX+grid[g].pos[0], offsetY+grid[g].pos[1],
                spriteSheet.spriteW, spriteSheet.spriteH);
        }
    }
    
    ctx.fillStyle = "#000000";
    ctx.textAlign = 'right';
    ctx.fillText("<< Back to menu", 290, 390);
}

function drawWon()
{
    ctx.textAlign = 'center';
    ctx.font = "bold 20pt sans-serif";
    ctx.fillStyle = "#000000";
    
    ctx.fillText(difficulties[gameState.mode].name, 150, 100);
    
    ctx.font = "italic 12pt sans-serif";
    var t = finishedTime;
    var cTime = "Completed in ";
    if((t/1000)>=60)
    {
        cTime+= Math.floor((t/1000)/60) + ":";
        t = t % (60000);
    }
    cTime+= (Math.floor(t/1000) < 9 ? "0" : "") + Math.floor(t/1000);
    ctx.fillText(cTime, 150, 120);
    
    if(gameState.newBest)
    {
        ctx.fillText("New best time!", 150, 140);
    }
    else
    {
        t = difficulties[gameState.mode].bestTime;
        cTime = "Best time ";
        if((t/1000)>=60)
        {
            cTime+= Math.floor((t/1000)/60) + ":";
            t = t % (60000);
        }
        cTime+= (Math.floor(t/1000) < 9 ? "0" : "") +
            Math.floor(t/1000);
        ctx.fillText(cTime, 150, 140);
    }
    
    ctx.fillText("(Click to jump to menu)", 150, 200);
}

function drawGame()
{
    if(ctx==null) { return; }
    if(!spritesLoaded) { requestAnimationFrame(drawGame); return; }
    
    // Frame & update related timing
    var currentFrameTime = Date.now();
    if(lastFrameTime==0) { lastFrameTime = currentFrameTime; }
    var timeElapsed = currentFrameTime - lastFrameTime;
    gameTime+= timeElapsed;
    
    // Update game
    updateGame();

    // Frame counting
    var sec = Math.floor(Date.now()/1000);
    if(sec!=currentSecond)
    {
        currentSecond = sec;
        framesLastSecond = frameCount;
        frameCount = 1;
    }
    else { frameCount++; }
    
    // Clear canvas
    ctx.fillStyle = "#ddddee";
    ctx.fillRect(0, 0, 300, 400);
    
    // Draw the current screen
    if(gameState.screen=='won') { drawWon(); }
    else if(gameState.screen=='playing') { drawPlaying(); }
    else if(gameState.screen=='menu') { drawMenu(); }
    
    // Draw the frame count
    ctx.textAlign = "left";
    ctx.font = "10pt sans-serif";
    ctx.fillStyle = "#000000";
    ctx.fillText("Frames: " + framesLastSecond, 5, 15);
    
    // Update the lastFrameTime
    lastFrameTime = currentFrameTime;
    
    // Wait for the next frame...
    requestAnimationFrame(drawGame);
}

function realPos(x, y)
{
    var p = document.getElementById('game');
    
    do {
        x-= p.offsetLeft;
        y-= p.offsetTop;
        
        p = p.offsetParent;
    } while(p!=null);
    
    return [x, y];
}