Session 13:

Memory Game Prototype, part 2

Web Coding

Intro to Web Development and Game Prototyping

Andrea Ida Malkah Klaura @ dieAngewandte

Where we left of:

a first prototype

memoroji! prototype v1

Still missing functionality:

  • Implementation of buttons
  • Storage of current state and favourite
  • Difficulty, health and stats

to be continued next session

start new & restart

's easy

We already start a new game when the page is loading:


              $( document ).ready(function () {
                init()
              })
            

So let's do the same thing, when someone clicks the "start new" button:


              $( document ).ready(function () {
                init()
                $( '#button-start-new' ).on('click', init)
              })
            

's a bit more work

but not a lot


              function reset () {
                // just hide and unsolve all items no matter what there previous state was
                for (let row=0; row<4; row++) {
                  for (let col=0; col<6; col++) {
                    gameState.grid[row][col].hidden = true
                    gameState.grid[row][col].solved = false
                  }
                }
                $( '.card' ).children().hide()
              }
            

And of course we also have to add an event listener to the reset button:


              $( document ).ready(function () {
                init()
                $( '#button-start-new' ).on('click', init)
                $( '#button-restart' ).on('click', reset)
              })
            

now for the favourites

to add a bit of trickyness

Lets start with two empty functions as event handlers for the two buttons:


              function toggleFavourite () {
                console.log('toggling favourite: not yet implemented')
              }

              function startFavourite () {
                console.log('starting favourite: not yet implemented')
              }

              $( document ).ready(function () {
                init()
                $( '#button-start-new' ).on('click', init)
                $( '#button-restart' ).on('click', reset)
                $( '#button-favourite' ).on('click', toggleFavourite)
                $( '#button-start-favourite' ).on('click', startFavourite)
              })
            

Let's first implement the toggleFavourite function. And also create a helper function so we can check if the current game setup has the same items as our favourite:


              function currentGameIsFavourite () {
                // if no favourite is set yet, we can already return false
                if (!gameState.favourite) return false

                // now check for every item in the current item set if it occurs in favourite
                // if all items are found, we have the same game. otherwise we can return false
                for (let i in currentGameItems) {
                  if (!gameState.favourite.includes(currentGameItems[i])) return false
                }
                return true
              }

              function toggleFavourite () {
                if ( currentGameIsFavourite() ) {
                  gameState.favourite = false
                  $( '#button-favourite' ).removeClass('highlight-green')
                } else {
                  gameState.favourite = currentGameItems
                  $( '#button-favourite' ).addClass('highlight-green')
                }
              }
            

We also have to add this CSS class to our style file:


              .highlight-green {
                background-color: lightgreen;
              }
            

Now we have to add something to the init function! Because after every new game is generated we have to check if it coincidentally has the same items as (and therefore is) our favourite.

So in the init() function add the end let's add a check:


              // now create the memory cards from this grid
              createCards(gameState.grid)

              if ( currentGameIsFavourite() ) {
                $( '#button-favourite' ).addClass('highlight-green')
              } else {
                $( '#button-favourite' ).removeClass('highlight-green')
              }
              gameState.status = 'initialised'
            

For the feature we almost have to do the same as in the init function, except for the initial filling of the currentGameItems.

So lets try to be DRY( Don't Repeat Yourself ) and refactor the init() first:


              function init () {
                // create new random array of current game items
                let memoryPool = [...memoryItemPool]
                currentGameItems = []
                while (currentGameItems.length < 12) {
                  let i = randInt(memoryPool.length)
                  let [item] = memoryPool.splice(i, 1)
                  currentGameItems.push(item)
                }
                // Now run the stage 2 of initialisation
                // (which is the same for a new as well as favourite games)
                initStage2()
              }

              function initStage2 () {
                // create a grid with pairs of game items
                let currentItemPool = [...currentGameItems]
                currentItemPool.push(...currentGameItems)
                gameState.grid = []
                for (let row=0; row<4; row++) {
                  gameState.grid.push([])
                  for (let col=0; col<6; col++) {
                    let i = randInt(currentItemPool.length)
                    let [item] = currentItemPool.splice(i, 1)
                    gameState.grid[row].push({
                      item: item,
                      hidden: true,
                      solved: false,
                    })
                  }
                }

                // now create the memory cards from this grid
                createCards(gameState.grid)

                if ( currentGameIsFavourite() ) {
                  $( '#button-favourite' ).addClass('highlight-green')
                } else {
                  $( '#button-favourite' ).removeClass('highlight-green')
                }
                gameState.status = 'initialised'
              }
            

Now the only thing left to do for the startFavourite() is:


              function startFavourite () {
                // just create the currentGameItems as a copy from our favourites and then
                // continue with stage 2 of the initialisation process
                currentGameItems = [...gameState.favourite]
                initStage2()
              }
            

About debugging...

... it can be quite tedious

A general word of guidance: Use the console, young Codawan!


              // we can do all the things we do in the script also live on the console:
              $( '.card' ).children().toggle()
            

Let's add two little helper functions we can use on the console quickly:


              // the following functions are just helping us in debugging
              // tac: toggle all cards
              function tac () {
                $( '.card' ).children().toggle()
              }

              // lac: log all cards
              function lac () {
                console.log("Current game grid:")
                for (let i in gameState.grid) {
                  let line = ''
                  for (let k in gameState.grid[i]) {
                    line += gameState.grid[i][k].item + ' '
                  }
                  console.log(line)
                }
              }
            

Now for the stats

We'll leave the hearts out in this version (similar to difficulty). But we'll add also counters for games started and completed, and an average for how many cards have been uncovered per game.

So let's change the stats object in the gameState from:


              stats: {
                health: 0,
                count: 0,
              },
            

to:


              stats: {
                currentCount: 0,
                gamesStarted: 0,
                gamesCompleted: 0,
                countPerCompleted: 0,
              },
            

Before we update the stats themselves, let's create a function to update the actual display of these stats.


              function renderStats () {
                $stats = $( '#stats' )
                $stats.children().remove()
                $stats.append(
                  $( `
Cards opened in the current game: ${gameState.stats.currentCount}
` ) ) $stats.append( $( `
Games started: ${gameState.stats.gamesStarted}
` ) ) $stats.append( $( `
Games completed: ${gameState.stats.gamesCompleted}
` ) ) $stats.append( $( `
Average cards opened in all completed games: ${gameState.stats.countPerCompleted}
` ) ) }

Now let's update and rerender the stats after a card is opened. We have to add some code at the right place in the clickMemoryCard function.


              if ( gameState.grid[row][col].hidden ) {
                gameState.grid[row][col].hidden = false
                // reveal the card slowly and afterwards process the grid
                $card.children().show( 'slow', processGrid )
                // increase the current stat counter and rerender stats
                gameState.stats.currentCount++
                renderStats()
              }
            

Do the same for games started, at the end of the initStage2 function:


              gameState.status = 'initialised'
              gameState.stats.gamesStarted++
              gameState.stats.currentCount = 0
              renderStats()
            

Now for the completed games. We have to check in the processGrid function, after a pair was found, if all the cards have been uncovered and update the game state accordingly.


              if (openUnsolved[0].card.item === openUnsolved[1].card.item) {
                openUnsolved[0].card.solved = true
                openUnsolved[1].card.solved = true
                gameState.processing = false
                // if all cards are now opened, the game is completed
                if ( isGameCompleted() ) {
                  gameState.status = 'done'
                  let stats = gameState.stats  // just to make the next lines a bit shorter
                  let totalClicks = stats.countPerCompleted * stats.gamesCompleted + stats.currentCount
                  stats.gamesCompleted++
                  stats.countPerCompleted = totalClicks / stats.gamesCompleted
                  renderStats()
                }
                return
              }
            

We still need to implement the isGameCompleted function.


              function isGameCompleted () {
                //if (gameState.stats.currentCount > 3) return true  // use this line for debugging
                // the game is completed, if all cards on the grid have set solved to true
                for (let x in gameState.grid) {
                  for (let y in gameState.grid[x]) {
                    if (!gameState.grid[x][y].solved) return false
                  }
                }
                return true
              }
            

Done!

Is it?

Bug invasion!

A bug was found when starting a favourite game. This only came up when testing the gamesStarted counter. What if the user clicks on but there is no favourite set?

Let's do a simple fix for now


              function startFavourite () {
                // if no fave is set, just do a simple alert for a start
                if (!gameState.favourite) {
                  alert('You did not set a favourite game.')
                  return
                }
                // just create the currentGameItems as a copy from our favourites and then
                // continue with stage 2 of the initialisation process
                currentGameItems = [...gameState.favourite]
                initStage2()
              }
            

Storing our state

In order to not loose our game progress, when we close the browser window, we can facilite localStorage.

Besides clearing the whole storage and removing single items (check the link above for details), we can set and get items in the web browser storage for this page like that:


              localStorage.setItem('aFunnyVar', 'holds some random content here')
              const funnyVar = localStorage.getItem('aFunnyVar')
            

Use the "Storage" section in you web dev tools to inspect your "Local Storage"

But we can only store strings in localStorage. It would be more convenient to be able to just store our gameState object.

Well ... JSON FTW! Specifically the JSON.stringify() and JSON.parse() functions.


              // try it out in the console of an opened game
              let serializedGameState = JSON.stringify(gameState)
              console.log(serializedGameState)

              // and to unserialize (or unstringify):
              let newGameState = JSON.parse(serializedGameState)
              console.log(newGameState)
            

With that, storing and loading the gameState is easy as that:


              function storeState () {
                localStorage.setItem('gameState', JSON.stringify(gameState))
              }

              function loadState () {
                gameState = JSON.parse(localStorage.getItem('gameState'))
              }
            

But when we execute the loadState function, we'll get an error, because our gameState is defined as a constant. And here we are not only changing the contents of the gameState object, but the object itself. So let's change


              const gameState = {
            

to:


              let gameState = {
            

Now we only have to store the game state, whenever anything relevant happens. In our case:

  • Whenever a card is uncovered
  • Whenever a new (or favourite) game is started
  • Whenever the game is reset

While the advanced version would be to write setters and getters for our gameState object, which handles storing automagically, the quick fix is to add storeState() to the end of the reset function and somewhere in the renderState function.

And when we load the page, and there is already a stored gameState, we'll have to skip initialisation and only create the actual HTML cards accordingly.

First, we'll update our document-ready function:


              $( document ).ready(function () {
                if (!localStorage.getItem('gameState')) {
                  init()
                } else {
                  loadState()
                }
                $( '#button-start-new' ).on('click', init)
                $( '#button-restart' ).on('click', reset)
                $( '#button-favourite' ).on('click', toggleFavourite)
                $( '#button-start-favourite' ).on('click', startFavourite)
              })
            

Now we have to extend the loadState function to handle rerendering:


              function loadState () {
                gameState = JSON.parse(localStorage.getItem('gameState'))
                createCards(gameState.grid)
                // as all cards are created hidden by default, show all those cards that are already opened
                for (let x in gameState.grid) {
                  for (let y in gameState.grid[x]) {
                    if (!gameState.grid[x][y].hidden) {
                      $( '#cards' ).children().eq(x).children().eq(y).children().show()
                    }
                  }
                }
                renderStats()
              }
            

Done!?!

Well, there is still a little bug with the favourites, because we loose the currentGameItems on a reload. Because they are not part of the gameState. But this could be changed with some refactoring.

But ...

I think we are good for now

Our extended prototype

memoroji! prototype v2

Still missing from the initial concept:

  • Difficulty and health
  • Some feedback/congrats when the memory is solved
  • And probably some (or a lot of) styling

What up next?

You could:

  • Improve the styling, to make the game visually appealing
  • Use images or other items for the memory cards
  • Use effects / animations / sounds on success or errors
  • Implement some of the missing functionality
  • Only show the start favourite button, if there is a favourite set

Minimal effort for this exercise: some styling

If you want to go for substantial extensions and changes, this can already count towards your final project.