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

Top comments (0)