Build Your First Game in JavaScript

Erik Onarheim

About me

  • Software Engineer
  • Hobbyist Game Developer
  • Open Source
  • Runner

erikonarheim.com

Making Games in the Web!?!

HTML5 Canvas

The HTML Canvas Element 

// Create canvas
const canvas = document.createElement('canvas');

// Set resolution in pixels
canvas.width = 800;
canvas.height = 600;
<!-- Create canvas and set resolution in pixels -->
<canvas width="800" height="600"></canvas>

Creating a Canvas

Sword
<canvas width="100" height="100"></canvas>

<style>
  canvas {
    width: 100px;
    height: 100px;
  }
</style>
Sword
<canvas width="100" height="100"></canvas>

<style>
  canvas {
    width: 200px;
    height: 100px;
  }
</style>

Resolution and Size

Painting that masterpiece

Graphics Context

const canvas = document.getElementById('my-game-id');

const graphicsContext = canvas.getContext('2d');

Rectangles

// Fill
graphicsContext.fillStyle = 'red';

// x, y, width, height
graphicsContext.fillRect(50, 50, 100, 100);


// Lines
graphicsContext.strokeStyle = 'black';
graphicsContext.lineWidth = '3px';

// x, y, width, height
graphicsContext.strokeRect(50, 50, 100, 100);

Circles

// Fill
graphicsContext.fillStyle = "blue";
// Lines
graphicsContext.strokeStyle = "black";
graphicsContext.lineWidth = 3;

// x, y, radius, start angle, end angle
graphicsContext.arc(150, 150, 100, 0, Math.PI * 2);
graphicsContext.fill();
graphicsContext.stroke();

Text

// Fill
graphicsContext.fillStyle = "lime";
// Lines
graphicsContext.strokeStyle = "black";
graphicsContext.lineWidth = 1;

// Text
graphicsContext.font = "80px Consolas";

// text, x, y
graphicsContext.fillText("Hello MDC 2020! ❤", 10, 150);
graphicsContext.strokeText("Hello MDC 2020! ❤", 10, 150);

Images

// Optional
graphicsContext.imageSmoothingEnabled = false;

// Load the image
const image = new Image();
image.src ="path/to/img.png";
image.onload = () => {
  // Draw the image
};
graphicsContext.drawImage(image, 0, 0);
graphicsContext.drawImage(image, 0, 0, 50, 50);
graphicsContext.drawImage(image,
                          // Source
                          96, 0, 32, 32,
                          // Destination
                          0, 0, 32, 32
                          );

HiDPI Displays

  • Logical Pixels
  • Device Pixels
const canvas = document.getElementById('hidpi-fix');
const logicalWidth = canvas.width;
const logicalHeight = canvas.height;

// Increase resolution
canvas.width = logicalWidth * window.devicePixelRatio;
canvas.height = logicalHeight * window.devicePixelRatio;


const img = new Image();
img.onload = () => {
  // Scale drawing context
  ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
  ctx.drawImage(img, 0, 0, logicalWidth, logicalHeight);
}

HiDPI Correction

#hidpi-fix {
  width: 400px;
  height: 400px;
}

Affine Transformations

Translate, Rotate, and Scale

// scale(x, y)
graphicsContext.scale(2, 2);
// translate(x, y)
graphicsContext.translate(x, y)
// rotate(radian)
graphicsContext.rotate(Math.PI / 2)

Save and Restore

graphicsContext.save()
graphicsContext.restore()
graphicsContext.translate(x, y);
graphicsContext.rotate(radians);
graphicsContext.scale(sx, sy);

graphicsContext.drawImage(mySprite, 0, 0);

Game Loop

Game Loop

let mainloop = () => {
  requestAnimationFrame(mainloop);
  // update and draw game
};
mainloop(); 

Timing & Frames Per Second

let lastTime = performance.now(); // Date.now()
let mainloop = () => {
  requestAnimationFrame(mainloop);

  const now = performance.now(); // Date.now()
  let elapsedMs = now - lastTime;
  
  let fps = 1 / (elapsedMs / 1000);

  // update & draw your game

  lastTime = now;
};
mainloop();
\frac{16.6ms}{frame} * \frac{1s}{1000ms} = \frac{0.00166s}{frame}
(\frac{0.00166s}{frame})^{-1} = \frac{60 frame}{s}

Time Based Movement

const updateNotTimeBased = (box: Box) => {
  box.x += box.v;
};

const updateTimeBased = (box: Box, delta: number) => {
  box.x += box.v * (delta / 1000);
};

Input

Keyboard

const keybuffer: string[] = [];
document.body.addEventListener("keydown", e => {
  const index = keybuffer.indexOf(e.key);
  if (index === -1) {
    keybuffer.push(e.key);
  }
});

document.body.addEventListener("keyup", e => {
  const index = keybuffer.indexOf(e.key);
  if (index > -1) {
    keybuffer.splice(index, 1);
  }
});

Pointer

const pos = {
  x: 0,
  y: 0
};

canvas.addEventListener("pointermove", e => {
  pos.x = e.clientX - canvas.offsetLeft;
  pos.y = e.clientY - canvas.offsetTop;
});

Audio

WebAudio API

AudioContext

const audioCtx = new AudioContext();
const play = document.getElementById('play');
play?.addEventListener('click', () => {
    if (audioCtx.state === 'suspended') {
        audioCtx.resume();
    }
    // play audio
});

AudioBufferSourceNode

const audioResponse = await fetch('./path/to/my/cool.mp3');
const rawAudioBuffer = await audioResponse.arrayBuffer();
const decodedBuffer = await audioCtx.decodeAudioData(rawAudioBuffer);

const audioSourceNode = audioCtx.createBufferSource();
audioSourceNode.buffer = decodedBuffer;

GainNode

const volumeNode = audioCtx.createGain();
volumeNode.gain.value = 0.5;

// https://developer.mozilla.org/en-US/docs/Web/API/AudioParam/setTargetAtTime
// After each .1 seconds timestep, the target value will ~63.2% closer.
// This exponential ramp provides a more pleasant transition in gain
volumeNode.setTargetAtTime(0.5, audioCtx.currentTime, 0.1);

Connecting and Playing

// Connect the graph
audioSourceNode.connect(volumeNode).connect(audioCtx.destination);

// Play AudioBufferSourceNode.start([when][, offset][, duration]);
audioSourceNode.start(0, 0);

Collisions

 

Circles

Bounding Box

Tile Based

Grouping

Resolution

Newish and Upcoming Stuff

  • GamePad API
  • Fullscreen API
  • WebGL2
  • WebGPU
  • WebUSB

Game Engines

Excalibur Start

// index.ts
import { Engine, Loader } from "excalibur";

const game = new Engine({
  width: 800,
  height: 600
});
game.setAntialiasing(false);

const loader = new Loader();

// Add things to loader
// Make a game!

game.start(loader);
npm install excalibur --save-exact
<script src="https://unpkg.com/excalibur@latest/dist/excalibur.min.js" />

Resources

// index.ts
import { Resources } from "./resources";
// ...

const loader = new Loader();

// Add things to loader
for (let r in Resources) {
  loader.addResource(Resources[r]);
}

// Make a game!

game.start(loader);
// resources.ts

import { Texture } from "excalibur";

import botImage from "./img/excalibot.png";
import blockImage from "./img/block.png";
import jumpSound from './snd/jump.wav';
import coinSound from './snd/coin.wav';

export const Resources = {
  bot: new Texture(botImage),
  block: new Texture(blockImage),
  jump: new Sound(jumpSound),
  coin: new Sound(coinSound)
};

Scenes & Actors

const game = new Engine(...)
                        
const scene = new Scene(game);

const actor = new Actor({
  x: 50,
  y: 50,
  width: 32,
  height: 32
});

scene.add(actor);

// Tranistion to the scene
game.add("level", scene);
game.goToScene("level");
export class Player extends Actor {
  constructor(x, y, width, height) {
    super({x, y, width, height});
  }
  
  onInitialize(engine: Engine) {
    // initialize logic 
  }
  
  onPostUpdate(engine: Engine, delta: number) {
    // do stuff every update
  }
  
  onPostDraw(ctx: CanvasRenderingContext2D) {
  	// custom drawing
  }
}

Animation

const spriteSheet = new SpriteSheet(Resources.bot, 8, 1, 32, 32);
const idleAnimation = spriteSheet.getAnimationByIndices(
  engine,
  [2, 3],
  800
);

actor.addDrawing("idle", idleAnimation);
actor.setDrawing("idle");

Input

// actor.ts


onPostUpdate(engine: Engine, delta: number) {
  // Player input
  if(engine.input.keyboard.isHeld(Input.Keys.Left)) {
    this.vel.x = -150;
  }

  if(engine.input.keyboard.isHeld(Input.Keys.Right)) {
    this.vel.x = 150;
  }

  if(engine.input.keyboard.isHeld(Input.Keys.Up) && this.onGround) {
    this.vel.y = -400;
    this.onGround = false;
  }
}

Collision

Prevent Passive Active Fixed
Prevent None None None None
Passive None Event Only Event Only Event Only
Active None Event Only Resolution Resolution
Fixed None Event Only Resolution None

// Default
actor.body.collider.type = CollisionType.PreventCollision;

actor.body.collider.type = CollisionType.Passive;

actor.body.collider.type = CollisionType.Active;

actor.body.collider.type = CollisionType.Prevent;

Audio

import { Resources } from './resources';


// Play the sound
Resources.jump.play();

// Pause the sound do not rewind
Resources.jump.pause();

// Stop the sound and rewind
Resources.jump.stop();

Demo!

Resources

excaliburjs.com

Game Programming Patterns (link)

MDN: Intro to Game Development (link)

JSFXR (link)

WebGL Fundamentals (link)

TWGL (link)

Made with Slides.com