Naively Making a Simple 2D Action Browser-based Game Prototype with JavaScript and Canvas API

farishan

Faris Han

Posted on February 8, 2023

Naively Making a Simple 2D Action Browser-based Game Prototype with JavaScript and Canvas API

TL;DR final result: https://farishan.itch.io/wild-tile

Hook

"A Tile goes wild when rendered forcefully by the universe. You are assigned to control it. Be the most agile and longest-lasting tile controller!"

Game Objective

Control a tile so it doesn't hit the edge of the game area, for as long as you can.

Game Rules

  • Control the tile with W/A/S/D key
  • Do not hit the edge of the game area
  • Tile speed increases with time
  • Last speed and last time should be recorded
  • Highest speed and longest time should be recorded

Step 1. Setup

  • project-folder
    • index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Wild Tile</title>
    <style>
      * {
        box-sizing: border-box;
      }
      html,
      body {
        height: 100%;
      }
      body {
        margin: 0;
        display: flex;
        justify-content: center;
        align-items: center;
      }
    </style>
  </head>
  <body>
    <script>
      const canvas = document.createElement('canvas')
      canvas.width = 640 // px
      canvas.height = 360 // px
      canvas.style.outline = '1px solid'
      canvas.style.display = 'block'
      document.body.append(canvas)
    </script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Setup Result

Step 2. Draw a Tile

<html>
  <body>
    <script>
      // previous code... 
      canvas.style.outline = '1px solid'
      canvas.style.display = 'block'
      document.body.append(canvas)

      const COLUMN = 16
      const ROW = 9
      const TILE_WIDTH = canvas.width/COLUMN // px
      const TILE_HEIGHT = canvas.height/ROW // px

      const display = canvas.getContext('2d')
      display.fillRect(0, 0, TILE_WIDTH, TILE_HEIGHT)
    </script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Step 2 result

Step 3. Move the Tile

<html>
  <body>
    <script>
      // previous code... 
      const display = canvas.getContext('2d')
      display.fillRect(0, 0, TILE_WIDTH, TILE_HEIGHT)

      function createLoop(setting = {}) {
        const {fpsLimit = 5, onTick = () => {}} = setting

        const loop = {
          shouldRun: false,
          fpsLimit,
          tickInterval: 1000/fpsLimit,
          thenTime: performance.now(),
          frameCount: 0,
          elapsedTime: 0,
          gapTime: 0,
        }

        loop.check = function(nowTime) {
          this.elapsedTime = nowTime - this.thenTime // ms

          if (this.elapsedTime > this.tickInterval) {
            this.frameCount++
            this.gapTime = this.elapsedTime % this.tickInterval
            this.thenTime = nowTime - this.gapTime

            onTick()
          }
        }

        loop.start = function() {
          this.shouldRun = true

          const callback = (nowTime) => {
            if (!this.shouldRun) return

            this.check(nowTime)

            requestAnimationFrame(callback)
          }

          callback()
        }

        loop.stop =  function() {
          this.shouldRun = false
        }

        return loop
      }

      let col = 0
      let row = 0
      let updateLoop
      let renderLoop

      function update() {
        col = col + 1
      }

      function render() {
        display.clearRect(0, 0, canvas.width, canvas.height)
        display.fillRect(
          col*TILE_WIDTH,
          row*TILE_HEIGHT,
          TILE_WIDTH,
          TILE_HEIGHT
        )
      }

      updateLoop = createLoop({fpsLimit: 15, onTick: update})
      renderLoop = createLoop({fpsLimit: 60, onTick: render})

      updateLoop.start()
      renderLoop.start()

      setTimeout(() => {
        updateLoop.stop()
        renderLoop.stop()
      }, 1000)
    </script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Step 4. Add "Game Over" condition

<html>
  <body>
    <script>
      // previous code...
      let col = 0
      let row = 0
      let updateLoop
      let renderLoop

      function update() {
        if(col + 1 >= COLUMN) {
          updateLoop.stop()
          renderLoop.stop()
          const result = document.createElement('div')
          result.style.position = 'absolute'
          result.style.fontSize = '32px'
          result.innerText = 'Game Over!'
          document.body.append(result)
          return
        }

        col = col + 1
      }

      function render() {
        display.clearRect(0, 0, canvas.width, canvas.height)
        display.fillRect(
          col*TILE_WIDTH,
          row*TILE_HEIGHT,
          TILE_WIDTH,
          TILE_HEIGHT
        )
      }

      // some code...

      // delete this block below
      setTimeout(() => {
        updateLoop.stop()
        renderLoop.stop()
      }, 1000)
    </script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Step 4 result

Step 5. Control the Tile

<html>
  <body>
    <script>
      // previous code...
      let col = 0
      let row = 0
      let updateLoop
      let renderLoop
      let direction = 'right'
      let nextDirection = 'right'

      function update() {
        if (
          direction === 'right' && nextDirection === 'left'
          || direction === 'left' && nextDirection === 'right'
          || direction === 'up' && nextDirection === 'down'
          || direction === 'down' && nextDirection === 'up'
        ) {
          nextDirection = direction
        }

        if(
          (nextDirection === 'right' && col + 1 >= COLUMN)
          || (nextDirection === 'left' && col - 1 < 0)
          || (nextDirection === 'down' && row + 1 >= ROW)
          || (nextDirection === 'up' && row - 1 < 0)
        ) {
          updateLoop.stop()
          renderLoop.stop()
          const result = document.createElement('div')
          result.style.position = 'absolute'
          result.style.fontSize = '32px'
          result.innerText = 'Game Over!'
          document.body.append(result)
          return
        }

        if (nextDirection === 'right') {
          col = col + 1
        }
        else if(nextDirection === 'left') {
          col = col - 1
        }
        else if(nextDirection === 'up') {
          row = row - 1
        }
        else if(nextDirection === 'down') {
          row = row + 1
        }

        direction = nextDirection
      }

      // some code...

      const KEY_MAP = {
        w: 'up',
        a: 'left',
        s: 'down',
        d: 'right'
      }

      window.addEventListener('keydown', e => {
        if (KEY_MAP[e.key]) {
          nextDirection = KEY_MAP[e.key]
        }
      })
    </script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Notice that I use movement mechanic like the Snake game, so the player can't use "right-left-right-left-repeat" pattern.

Step 6. Add auto-increment speed system

<html>
  <body>
    <script>
      // previous code...

      function createLoop(setting = {}) {
        const {fpsLimit = 5, onTick = () => {}} = setting

        const loop = {
          shouldRun: false,
          fpsLimit,
          tickInterval: 1000/fpsLimit,
          thenTime: performance.now(),
          frameCount: 0,
          elapsedTime: 0,
          gapTime: 0,
        }

        loop.changeFpsLimit = function(newLimit) {
          this.fpsLimit = newLimit
          this.tickInterval = 1000/newLimit
        }

        // some code...

        return loop
      }

      // some code...

      setInterval(() => {
        updateLoop.changeFpsLimit(updateLoop.fpsLimit + 1)
      }, 3000)
    </script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Step 7. Add HUD (heads-up display)

<html>
  <body>
    <script>
      // previous code...
      const canvas = document.createElement('canvas')
      canvas.width = 640 // px
      canvas.height = 360 // px
      canvas.style.outline = '1px solid'
      canvas.style.display = 'block'

      const container = document.createElement('div')
      container.style.width = canvas.width+'px'
      container.style.height = canvas.height+'px'
      container.style.outline = '1px solid'
      container.style.position = 'relative'

      container.append(canvas)
      document.body.append(container)

      // some code...

      function render() {
        display.clearRect(0, 0, canvas.width, canvas.height)
        display.fillRect(
          col*TILE_WIDTH,
          row*TILE_HEIGHT,
          TILE_WIDTH,
          TILE_HEIGHT
        )
        renderHUD()
      }

      // some code...

      const hud = document.createElement('div')
      hud.style.position = 'absolute'
      hud.style.left = '4px'
      hud.style.top = '4px'

      function renderHUD() {
        hud.innerHTML = `Speed: ${updateLoop.fpsLimit} tile/s`
      }

      container.append(hud)
    </script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Step 7 result

Step 8. Scoring

<html>
  <body>
    <script>
      // previous code...
      let col = 0
      let row = 0
      let updateLoop
      let renderLoop
      let direction = 'right'
      let nextDirection = 'right'
      let startTime = performance.now()
      let elapsedTime = 0

      function loadData() {
        const data = localStorage.getItem('WILD_TILE_DATA')
        if (data) return JSON.parse(data)
        return false
      }

      function saveData(newData) {
        localStorage.setItem('WILD_TILE_DATA', JSON.stringify(newData))
      }

      const lastData = loadData()

      let lastSpeed = lastData ? lastData.lastSpeed : 0
      let lastTime = lastData ? lastData.lastTime : 0
      let topSpeed = lastData ? lastData.topSpeed : 0
      let topTime = lastData ? lastData.topTime : 0

      function handleGameOver() {
        updateLoop.stop()
        renderLoop.stop()
        const result = document.createElement('div')
        result.style.position = 'absolute'
        result.style.fontSize = '32px'
        result.innerText = 'Game Over!'
        document.body.append(result)

        lastSpeed = updateLoop.fpsLimit
        lastTime = elapsedTime
        topSpeed = lastData.topSpeed ?
          (updateLoop.fpsLimit > lastData.topSpeed ?
            updateLoop.fpsLimit
            : lastData.topSpeed
          )
          : updateLoop.fpsLimit
        topTime = lastData.topTime ?
          (elapsedTime > lastData.topTime ?
            elapsedTime
            : lastData.topTime)
          : elapsedTime

        saveData({
          lastSpeed,
          lastTime,
          topSpeed,
          topTime
        })
      }

      function update() {
        if (
          direction === 'right' && nextDirection === 'left'
          || direction === 'left' && nextDirection === 'right'
          || direction === 'up' && nextDirection === 'down'
          || direction === 'down' && nextDirection === 'up'
        ) {
          nextDirection = direction
        }

        if(
          (nextDirection === 'right' && col + 1 >= COLUMN)
          || (nextDirection === 'left' && col - 1 < 0)
          || (nextDirection === 'down' && row + 1 >= ROW)
          || (nextDirection === 'up' && row - 1 < 0)
        ) {
          handleGameOver()

          return
        }

        if (nextDirection === 'right') {
          col = col + 1
        }
        else if(nextDirection === 'left') {
          col = col - 1
        }
        else if(nextDirection === 'up') {
          row = row - 1
        }
        else if(nextDirection === 'down') {
          row = row + 1
        }

        direction = nextDirection
      }

      // some code...

      function renderHUD() {
        elapsedTime = performance.now() - startTime

        hud.innerHTML = `Speed: ${updateLoop.fpsLimit} tile/s | Time: ${((elapsedTime)/1000).toFixed(2)}s`
          + (
            lastData ? (
              `<br>Last Speed: ${lastSpeed} tile/s | Last Time: ${(lastTime/1000).toFixed(2)}s`
              + `<br>Speed Highscore: ${topSpeed} tile/s | Time Highscore: ${(topTime/1000).toFixed(2)}s`
            )
            : '')
      }

      container.append(hud)
    </script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Step 9. Restart the Game

<html>
  <body>
    <script>
      // previous code...
      let lastSpeed = lastData ? lastData.lastSpeed : 0
      let lastTime = lastData ? lastData.lastTime : 0
      let topSpeed = lastData ? lastData.topSpeed : 0
      let topTime = lastData ? lastData.topTime : 0

      const result = document.createElement('div')
      result.style.position = 'absolute'
      result.style.fontSize = '32px'
      result.style.textAlign = 'center'
      document.body.append(result)

      function restartHandler(e) {
        if (e.key === 'r') {
          startGame()
          window.removeEventListener('keydown', restartHandler)
        }
      }

      function setRestartHandler() {
        window.addEventListener('keydown', restartHandler)
      }

      function handleGameOver() {
        updateLoop.stop()
        renderLoop.stop()

        result.innerHTML = 'Game Over!<br>press "r" to restart'

        lastSpeed = updateLoop.fpsLimit
        lastTime = elapsedTime
        topSpeed = lastData.topSpeed ?
          (updateLoop.fpsLimit > lastData.topSpeed ?
            updateLoop.fpsLimit
            : lastData.topSpeed
          )
          : updateLoop.fpsLimit
        topTime = lastData.topTime ?
          (elapsedTime > lastData.topTime ?
            elapsedTime
            : lastData.topTime)
          : elapsedTime

        saveData({
          lastSpeed,
          lastTime,
          topSpeed,
          topTime
        })

        setRestartHandler()
      }

      // some code...

      container.append(hud)

      function keyboardListener(e) {
        if (KEY_MAP[e.key]) {
          nextDirection = KEY_MAP[e.key]
        }
      }

      let speedInterval;

      function startGame() {
        result.innerHTML = ''
        window.removeEventListener('keydown', keyboardListener)
        if (speedInterval) clearInterval(speedInterval)

        lastData = loadData()

        lastSpeed = lastData ? lastData.lastSpeed : 0
        lastTime = lastData ? lastData.lastTime : 0
        topSpeed = lastData ? lastData.topSpeed : 0
        topTime = lastData ? lastData.topTime : 0

        col = 0
        row = 0
        direction = 'right'
        nextDirection = 'right'
        startTime = performance.now()
        elapsedTime = 0

        window.addEventListener('keydown', keyboardListener)

        updateLoop = createLoop({fpsLimit: 5, onTick: update})
        renderLoop = createLoop({fpsLimit: 60, onTick: render})
        updateLoop.start()
        renderLoop.start()

        speedInterval = setInterval(() => {
          updateLoop.changeFpsLimit(updateLoop.fpsLimit + 1)
        }, 3000)
      }

      result.innerHTML = 'press "r" to start'
      setRestartHandler()
    </script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode
šŸ’– šŸ’Ŗ šŸ™… šŸš©
farishan
Faris Han

Posted on February 8, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related