Building a TypeScript Roguelike, Part 1: Tilemaps

I’ve been meaning to learn TypeScript for some time, so I’ve decided to build a roguelike (or at least begin to build a roguelike) in order to do some canvas programming as well.

This isn’t a guide, it’s a journal of my experiments, so follow at your own risk. I can’t guarantee I’m going to make the best design decisions right off the the bat.

I’ll be omitting most of the glue code from the articles, and I’m not planning a writeup of the build system, but you can see the full source code at the GitHub repo.

Designing the tile data

I want the game to be tile-based, so I’ll need some sort of generic tile container for doing things like rendering the map, generating levels, pathfinding, and so forth. For now, I’ll just represent the individual levels as tiles:

// src/Tile.ts

enum Tile {
  Nothing = 0,
  Floor = 1
}

Eventually I’m planning on making this an object or bitmask that stores additional information, but this will suffice for writing the container.

Row Major Ordering

Tilemaps should be as simple as possible; I want to make them cheap and reusable. The simplest way to store a grid of tiles would be an array of rowsq, where a hypothetical tileMap.at function would look like (x, y) => this[y][x]. This introduces a lot of overhead because of all the arrays. Fortunately it’s quite easy to turn this into a single array by arranging the elements like this:

const tileMap = [ 0, 0, 0, 0,
                  0, 0, 0, 0,
                  0, 0, 0, 0 ];

This is called a row-major order, and a tiles.at function would look like (width, x, y) => this[(y * width) + x].

I’d like to avoid tying higher-level code to this specific representation, so I’ve abstracted the coordinate system in case I want to swap it out. This is the code for a coordinate system:

// coords.ts

interface CoordinateSystem {
  getX(i: number): number;
  getY(i: number): number;
  getI(x: number, y: number): number;
}

class RowFirstCoordinates {
  public static GetX(i: number, width: number): number {
    return i % width;
  };
  public static GetY(i: number, width: number): number {
    return Math.floor(i / width);
  };
  public static GetI(x: number, y: number, width: number): number {
    return (y * width) + x;
  };

  // Instance methods allow you to store the width
  constructor(private width: number) { }
  public getX(i: number): number {
    return RowFirstCoordinates.GetX(i, this.width);
  }
  public getY(i: number): number {
    return RowFirstCoordinates.GetY(i, this.width);
  }
  public getI(x: number, y: number): number {
    return RowFirstCoordinates.GetI(x, y, this.width);
  }
}

This will allow us to convert in between coordinates and array indices. Now I have everything I need to start building the tilemaps.

Designing the TileMap API

I’d like tilemaps to have a fluent interface, but one that avoids mutating existing references. Here’s an example of the API I’m imagining:

const tiles = [ 0, 0, 0, 0, 0,
                0, 0, 0, 0, 0,
                0, 0, 0, 0, 0,
                0, 0, 0, 0, 0,
                0, 0, 0, 0, 0 ];
const mapA = new TileMap(tiles, 5);

// Create a region with x, y, width, and height
const regionB = {
  x:     1, y:      1,
  width: 2, height: 2
};

// Design note: Since each change creates and returns a new TileMap,
// it would be ideal to be able to batch these into some sort of
// execution-plan object that could be applied all at once, for cases
// where I don't care about the intermediate states.
const mapB = mapA
  .fillRegion(regionB, 1)
  .setAt(0, 3, 2)
  .setAt(4, 3, 2);

mapB.tiles === [ 0, 0, 0, 2, 0,
                 0, 1, 1, 0, 0,
                 0, 1, 1, 0, 0,
                 0, 0, 0, 0, 2,
                 0, 0, 0, 0, 0 ];

For now, I’m just going to create and implement the following interface, so I can get something on the screen:

// tileMaps.ts

import * as coords from "./coords";
import Tile from "./Tile";

// IRegion is used to represent an area without having to carry around
// a tile array.
interface IRegion {
  x: number;
  y: number;
  width: number;
  height: number;
}

type TileMapProcessor = { (tileMap: ITileMap): ITileMap }

interface ITileMap {
  width: number;
  height: number;
  tiles: Tile[];
  coords: coords.CoordinateSystem;
  getRegion: (region: IRegion) => ITileMap;
  fillRegion: (region: Iregion, contents: Tile) => ITileMap;
  applyProcessors: (processors: TileMapProcessor[]) => ITileMap;
}

You can see the beginnings of my desired interface in the signatures of getRegion, fillRegion, and applyProcessors - they return TileMaps so you can contine chaining operations. The methods do the following:

  • getRegion takes an IRegion and returns an ITileMap containing the size of that region, containing the tiles in that region.
  • fillRegion takes an IRegion and a Tile and returns a new ITileMap that is the result of setting all of the tiles in this ITileMap to contents.
  • applyProcessors takes a list of functions that transform an ITileMap to another ITileMap. It passes this map through those functions in the order given.

The implementation for this is fairly straightforward:

// tileMaps.ts

import { assert, zeroedArray } from "./util"

class TileMap {
  public static NewEmpty(width: number, height: number) {
    const length = width * height;
    const tiles: Tile[] = zeroedArray(length);
    return new TileMap(tiles, width);
  }

  public readonly tiles: Tile[];
  public coords: coords.RowFirstCoordinates;
  public readonly height: number;
    public readonly width: number;
  constructor(tiles: Tile[], width: number) {
    assert(tiles.length % width === 0);
    this.width = width;
    this.height = tiles.length / width;
    this.coords = new coords.RowFirstCoordinates(width);
    this.tiles = tiles;
  }

  public getRegion(region: IRegion): TileMap {
    const { x, y, width, height } = region;
    assert((x + width <= this.width) && (y + height <= this.height));
    const regionTiles = this.tiles.filter((tile, i) => {
      const tileX = this.coords.getX(i);
      const tileY = this.coords.getY(i);
      if ((tileX >= x && tileX < x + width) && (tileY >= y && tileY < y + height)) {
        return true;
      } else {
        return false;
      }
    });

    return new TileMap(regionTiles, width);
  }
  public fillRegion(region: IRegion, contents: number): TileMap {
    const { x, y, width, height } = region;
    assert((x + width <= this.width) && (y + height <= this.height));
    const newTiles = this.tiles.map((tile, i) => {
      const tileX = this.coords.getX(i);
      const tileY = this.coords.getY(i);
      if ((tileX >= x && tileX < x + width) && (tileY >= y && tileY < y + height)) {
        return contents;
      } else {
        return tile;
      }
    });

    return new TileMap(newTiles, this.width);
  }
  public applyProcessors(processors: TileMapProcessor[]): ITileMap {
    return processors.reduce((map, processor) => processor(map), this as ITileMap);
  }
}

I’ll make another type to represent renderers, and then I can move on to actually drawing something

// tileMaps.ts

type TileMapRenderer = { (tileMap: ITileMap): void };

Drawing a map

Finally, I’m ready to draw my first map. First I’ll get the canvas and create a game loop:

// main.ts

import * as tileMaps from "./tileMaps";

const canvas = <HTMLCanvasElement>document.getElementById("canvas");
const ctx = canvas.getContext("2d");
// Nothing's changing yet, so I only need to draw once
let drawn = false;
let time;

const gameLoop = (ctx: CanvasRenderingContext2D,
                  map: tileMaps.ITileMap,
                  renderer: tileMaps.TileMapRenderer) => {
  const now = new Date().getTime();
  const dt = now - (time || now);
  time = now;

  console.log(dt);

  const recurse = (delta) => gameLoop(delta, ctx, map, drawer);
  if (!rendered) {
    renderer(map);
    rendered = true;
  }
  requestAnimationFrame(recurse);
};

Now I just need a map and a renderer. I’ll generate a map with some simple rooms:

// main.ts

class Room extends tileMaps.TileMap {
  public readonly x;
  public readonly y;
  constructor(public readonly parent: tileMaps.ITileMap, region: tileMaps.IRegion) {
    super(parent.getRegion(region).tiles, region.width);

    this.x = region.x;
    this.y = region.y;
  }
}

function getRandomRooms(tileMap: tileMaps.ITileMap): Room[] {
  return zeroedArray(getRandomInt(3, 9)).map(() => {
    const width = getRandomInt(10, 20);
    const height = getRandomInt(10, 20);
    const x = getRandomInt(0, tileMap.width - width);
    const y = getRandomInt(0, tileMap.height - height);
    return new Room(tileMap, { x, y, width, height });
  });
}

function createRooms(tileMap: tileMaps.ITileMap): tileMaps.ITileMap {
  const rooms = getRandomRooms(tileMap, 10);

  return rooms.reduce(
    (map, room) => map.fillRegion(room, Tile.Floor), tileMap);
}

const mapCreationLayers = [
  createRooms,
];

You’ll notice the signature of createRooms: (tileMap: tileMaps.ITileMap): tileMaps.ITileMap. That’s the same type for which I defined the alias tileMaps.TileMapProcessor - meaning I can create an empty map and pass it through mapCreationLayers as follows:

// main.ts

const mapWidth = 120;
const mapHeight = 80;

const map = tileMaps.TileMap.NewEmpty(mapWidth, mapHeight)
  .applyProcessors(mapCreationLayers);

And finally, I write a function to render the tiles to the canvas:

// main.ts

const render = (tileMap: tileMaps.ITileMap) => {
  tileMap.tiles.map((tile, i) => {
    const x = map.coords.getX(i);
    const y = map.coords.getY(i);
    ctx.fillStyle = (tile === Tile.Floor) ? "#FFF" : "#000";
    ctx.fillRect(x * tileWidth, y * tileHeight, 10, 10);
  });
};

Eventually the tile colors will be drawn with a pixel buffer which is scaled up to the full size of the canvas, making drawing a grid of solid colors much faster. At the moment, however, I’m only rendering the map once - in the starting frame.

It’s all set - I can now kick off the game loop to start it off:

// main.ts

gameLoop(ctx, map, render);

Doesn’t look like much, but it’s a success! In the next post I’ll be adding more types of tiles and exploring ways to generate a level. You can also check out the GitHub repo, or try a running version at roguelike.benaiah.me.

If you’d like to leave a comment, please email benaiah@mischenko.com

Visit the Github Repo