Command pattern for canvas games

Despite popular opinion, I still belive in the use of design patterns where applicable. I've been reading through Bob Nystroms Game Programming Patterns from 2014. The great thing about these sort of programming books is that they hold up really well. The patterns present are just as relevant today as they were in 2014.

I've been writing lots of small html canvas games recently, and each time have to write some key handling code. This is normally to move the character around the screen and to allow it to perform certain actions. Normally I handle the input directly inside the update code. Which is fine for small demos, but the Command pattern offers abstraction and the benefit of undo/redo functionality.

Working demo

Using the Command pattern

In the following code, I set up an abstract class called Command. This will be extended by our custom commands and force each to implement an execute and undo method. For purposes of the demo I have added a currentMove variable to demonstrate how undo/redo may work in game. The currentMove value will track the index of the player's last executed MoveCommand. Each time the arrow keys are pressed the generated MoveCommand will be added to the commands array. In a real game the array will hold many different commands, for example item pick up, actions etc...Each command will allow the encapsulation of logic. For this demo the MoveCommand handles the player's movement logic.

Inside the game loop you can see that the handleInput method returns an instance of a Command. Becuase the logic is encapsulated we don't need to worry about what type of Command is returned. We just have to know to call execute and push the command to the list of previous moves. The execute method stores the last position so that it can be restored when undo is called.

The advantage of this pattern, if it is not already clear, is that we have implemented a trivial way of applying undo and redo.

import { isKeyPressed, clearPressedKeys, addListeners } from "./keys.js";

interface Vec2 {
  x: number;
  y: number;
}

abstract class Command {
  abstract execute(): void;
  abstract undo(): void;
}

class MoveCommand extends Command {
  private lastPos: Vec2;
  
  constructor(
    private pos: Vec2,
    private dx: number,
    private dy: number,
  ) {
    super();
  }

  execute() {
    this.lastPos = { ...this.pos };
    this.pos.x += this.dx;
    this.pos.y += this.dy;
  }

  undo() {
    this.pos.x = this.lastPos.x;
    this.pos.y = this.lastPos.y;
  }
}

const player = {
  x: 200,
  y: 200,
  w: 40,
  h: 40,
  spd: 20,
};

const history: Command[] = [];
let currentMove = 0;

function handleInput(): Command | null {
  if (isKeyPressed("ArrowUp")) return new MoveCommand(player, 0, -player.spd);
  if (isKeyPressed("ArrowDown")) return new MoveCommand(player, 0, player.spd);
  if (isKeyPressed("ArrowLeft")) return new MoveCommand(player, -player.spd, 0);
  if (isKeyPressed("ArrowRight")) return new MoveCommand(player, player.spd, 0);

  return null;
}

(function () {
  addListeners();

  const canvas = document.createElement("canvas")!;
  document.body.appendChild(canvas);
  const ctx = canvas.getContext("2d")!;
  const w = (canvas.width = 500);
  const h = (canvas.height = 500);

  function renderLoop() {
    requestAnimationFrame(renderLoop);

    const command = handleInput();

    if (command) {
      history.length = currentMove;

      command.execute();
      history.push(command);
      currentMove++;
    }

    if (isKeyPressed("z") && currentMove > 0) {
      currentMove--;
      history[currentMove]!.undo();
    }

    if (isKeyPressed("r") && currentMove < history.length) {
      history[currentMove]!.execute();
      currentMove++;
    }

    draw(ctx);

    clearPressedKeys();
  }

  renderLoop();
})();

function draw(ctx: CanvasRenderingContext2D) {
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);

  ctx.fillStyle = "#222";
  ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);

  ctx.fillStyle = "dodgerblue";
  ctx.fillRect(player.x - 20, player.y - 20, 40, 40);

  ctx.fillStyle = "white";
  ctx.font = "16px sans-serif";

  ctx.fillText("Arrow keys = move", 10, 25);
  ctx.fillText("Z = undo, R = redo", 10, 50);
  ctx.fillText(`Current Move: ${currentMove}`, 10, 75);
  ctx.fillText(`Total Moves: ${history.length}`, 10, 100);
}

The key handling boilerplate

const keysHeld = new Set();
const keysPressed = new Set();

export function isKeyDown(key: string) {
  return keysHeld.has(key);
}

export function isKeyPressed(key: string) {
  return keysPressed.has(key);
}

export function clearPressedKeys() {
  keysPressed.clear();
}

export function addListeners(anchor = window) {
  anchor.addEventListener("keydown", ({ key }: KeyboardEvent) => {
    if (!keysHeld.has(key)) keysHeld.add(key);
  });

  anchor.addEventListener("keyup", ({ key }: KeyboardEvent) => {
    if (keysHeld.has(key)) {
      keysHeld.delete(key);
      keysPressed.add(key);
    }
  });
}

Until next time,

- Brian