Naively Making a Simple 2D Action Browser-based Game Prototype with JavaScript and Canvas API
Faris Han
Posted on February 8, 2023
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>
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>
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>
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>
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>
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>
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>
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>
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>
š šŖ š
š©
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.