DEV Community

Faris Han
Faris Han

Posted on

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

Sentry image

See why 4M developers consider Sentry, “not bad.”

Fixing code doesn’t have to be the worst part of your day. Learn how Sentry can help.

Learn more

Top comments (0)

Sentry image

See why 4M developers consider Sentry, “not bad.”

Fixing code doesn’t have to be the worst part of your day. Learn how Sentry can help.

Learn more