This is the first of a few posts about creating a roguelike for the 2021 “RoguelikeDev Does The Complete Roguelike Tutorial” event hosted on reddit.com/r/roguelikedev and in the RoguelikeDev Discord.

I’ll be following the tutorial in TypeScript.

Since 2020, the tutorial has been using ECS architecture. There are a number of ECS libraries available, and I messed with creating my own before deciding on @fritzy/ape-ecs. The API was welcoming and documentation is good.

rot.js is a great roguelike development toolkit for JavaScript programming, and is how I’m rendering to the screen and performing FOV calculations.

There’s quite a bit here, it should be better paced in the future. There’s a working dungeon at the bottom you can play with.

Rebuilding the Dungeon in TypeScript

About a year ago I recreated parts of the Brogue dungeon generation algorithm in JavaScript, which I’ve written about here: here. The first task was to migrate that code to TypeScript.

Types

First, a quick look at most of the types:

export type CellConstant = typeof CELL_TYPES[keyof typeof CELL_TYPES];
type CellFlags = {
    OBSTRUCTS_PASSIBILITY?: boolean;
    OBSTRUCTS_VISION?: boolean;
    YIELD_LETTER?: boolean;
};
type CellType = {
    type: string;
    color: {fg: string; bg: string; dances?: boolean};
    letter: string;
    priority: number;
    flags: CellFlags;
    glowLight?: LightSource;
};
type RoomType = typeof ROOM_TYPES[keyof typeof ROOM_TYPES];
type FeatureType = typeof DUNGEON_FEATURE_CATALOG[keyof typeof DUNGEON_FEATURE_CATALOG];
type CellularAutomataRules = typeof CA.rules[keyof typeof CA.rules];
type Directions = typeof DIRECTIONS[keyof typeof DIRECTIONS];
type CardinalDirections = typeof CARDINAL_DIRECTIONS[keyof typeof CARDINAL_DIRECTIONS];
type DoorSite = {x: number; y: number; direction: CardinalDirections};
type DoorSites = Array<DoorSite>;

These are types used in the generation of the dungeon. CELL_TYPES, ROOM_TYPES, etc. are objects stored as constants.

Here are the types for performing dungeon lighting, and finally baking the final dungeon:

type ColorString = `#${number}${number}${number}`;
type ColoredCell = {bg: ColorString; fg: ColorString};
type DungeonCell = CellConstant | ColoredCell;
type AnnotatedCell = CellType & {constant: CellConstant};
type Grid<T = DungeonCell> = Array<Array<T>>;
type RGBColor = {r: number; g: number; b: number; alpha?: number};

type CellColorLayer = RGBColor & {
    dancing?: {
        deviations: {r: number; g: number; b: number};
        period: number;
    };
};
type CellColor = {fg: CellColorLayer; bg: CellColorLayer};
type LightSource = {
    minRadius: number;
    maxRadius: number;
    fade: number;
    color: {
        baseColor: RGBColor;
        variance: RGBColor & {
            overall: number;
        };
    };
};

type RandomColorDefiniton = {
    bg: {
        baseColor: RGBColor;
        noise: RGBColor & {overall: number};
        variance: RGBColor & {overall: number};
    };
    fg: {
        baseColor: RGBColor;
        noise: RGBColor & {overall: number};
        variance: RGBColor & {overall: number};
    };
};

type PerlinColorDefinition = {
    bg: {
        baseColor: RGBColor;
        variance: RGBColor & {overall: number};
    };
    fg: {
        baseColor: RGBColor;
        variance: RGBColor & {overall: number};
    };
};

PerlinColorDefinition and RandomColorDefinition are the types for how color configurations are stored in constants.

ECS

ECS architectures require all logic to be split into systems, which query and operate on entities, which have specific components, which are the collections of information about the entitiy. To explain, here’s an example of a system, component, and entity:

The PlayerRender system, which operates on entities that have the components:

  1. Character, a “tag” indicating it’s the player character
  2. Position, a component which holds x and y coordinates
  3. Renderable, a component which holds data for drawing any thing
export default class PlayerRender extends System {
    // ...
    init() {
        this.query = this.world.createQuery().fromAll(Character, Position, Renderable);
    }

    update(dt: number) {
        for (const entity of this.query.refresh().execute()) {
            const positionComponent = entity.getOne(Position);
            const renderableComponent = entity.getOne(Renderable);
            this.display.draw(
                positionComponent.x,
                positionComponent.y,
                renderableComponent.char,
                renderableComponent.fgColor,
                renderableComponent.bgColor
            );
        }
    }
}

The Character, Position, and Renderable components:

// Character is only a "tag," a component that stores no data
export class Character extends Component({}) {}
export class Position extends Component<{x: number; y: number}>({x: 0, y: 0}) {}
export class Renderable extends Component({
    char: '@',
    baseFG: {r: 0, g: 0, b: 0, alpha: 0},
    fg: {r: 0, g: 0, b: 0, alpha: 0},
    baseBG: {r: 0, g: 0, b: 0, alpha: 0},
    bg: {r: 0, g: 0, b: 0, alpha: 0},
}) {}

The player entity, which is the only entity that has all three of these Components, and therefore is the only entity this system will find when querying:

const player = this.world.createEntity({
    c: [
        {type: Character},
        {type: PlayerControlled},
        {type: Position, x: 8, y: 12},
        {
            type: Renderable,
            char: '@',
            baseBG: {r: 0, g: 0, b: 0, alpha: 0},
            bg: {r: 0, g: 0, b: 0, alpha: 0},
            baseFG: {r: 150, g: 150, b: 150, alpha: 1},
            fg: {r: 150, g: 150, b: 150, alpha: 1},
        },
        {type: Visible},
    ],
});

The main game logic is something like this:

window.addEventListener('keydown', e => {
    const entities = this.world.createQuery().fromAll(PlayerControlled).refresh().execute();
    for (const player of entities) {
        switch (e.code) {
            case 'ArrowUp':
                player.addComponent({
                    type: ActionMove,
                    y: -1,
                    x: 0,
                });
                break;
            // etc...
        }
    }
    // process actions with the ActionSystem, which moves the Position of any entity
    // with an ActionMove component, then removes the ActionMove component.
    // only process Actions when the user provides input.
    this.world.runSystem(ActionSystem);
});

while (true) {
    this.world.runSystem(RenderSystem);
}

Rendering happens independently of input, but player movement (and eventually NPC movement) will only happen when the user provides input, like other turn-based roguelikes.

Rendering independently of input allows for “dancing” colors, something that exists in Brogue that I really enjoy. A tile with “dancing” colors will slightly vary color even when the player is idle, which gives the dungeon a lot of life. Here’s a lake, which has dancing colors:

An animation of a text-based dungeon showing a lake made of cells of different shades of blue. The shades of blue flicker lighter and darker over time, like there's light playing off rippling surface of the water.

The dungeon generation algorithm can spit out tiles that have the following defined for their foreground (letter) or background color:

dancing?: {
    deviations: {r: number; g: number; b: number};
    period: number;
};

A tile with a dancing color is given the DancingColor component which is handled in the Render system like this:

// ...
// assuming a `bg` and `fg` from the Renderable component
let dancingColor = entity.getOne(DancingColor);
if (dancingColor.timer <= 0) {
    const dancingDeviations = {
        ...dancingColor.deviations,
    };
    // reset the dancing component's timer to be equal to its period
    dancingColor.update({timer: dancingColor.period});
    // mix the current background with the dancing color's deviations
    const mixed = ROTColor.randomize(
        [baseBG.r, baseBG.g, baseBG.b],
        [dancingDeviations.r, dancingDeviations.g, dancingDeviations.b]
    );
    // store the new color on the renderable component
    renderable.update({
        bg: {
            r: mixed[0],
            g: mixed[1],
            b: mixed[2],
        },
    });
}
// tick down the dancing color's timer
dancingColor.update({timer: dancingColor.timer - dt});

This requires the Renderable component to store a baseBG and baseFG in addition to its current bg and fg. If Renderable just stores a single color, dancing colors will perform a random walk and things get psychedelic:

An animation of a text-based dungeon showing a lake made of cells of different shades of blue. The cells randomly shift to shades of blue, red, and brown that don't resemble water.

The following are the current components defined for the game, including their types:

export class ActionMove extends Component<{x: number; y: number}>({x: 0, y: 0}) {}

export class DancingColor extends Component<{
    deviations: {r: number; g: number; b: number};
    period: number;
    timer: number;
}>({
    deviations: {r: 0, g: 0, b: 0},
    period: 0,
    timer: 0,
}) {}

export class Light extends Component<{
    base: {r: number; g: number; b: number; alpha: number};
    current: {r: number; g: number; b: number; alpha: number};
}>({
    base: {r: 0, g: 0, b: 0, alpha: 0},
    current: {r: 0, g: 0, b: 0, alpha: 0},
}) {}

export class Position extends Component<{x: number; y: number}>({x: 0, y: 0}) {}
export class Renderable extends Component({
    char: '@',
    baseFG: {r: 0, g: 0, b: 0, alpha: 0},
    fg: {r: 0, g: 0, b: 0, alpha: 0},
    baseBG: {r: 0, g: 0, b: 0, alpha: 0},
    bg: {r: 0, g: 0, b: 0, alpha: 0},
}) {}
export class Visible extends Component({}) {}
export class Memory extends Component({}) {}

export class Tile extends Component<{
    flags: {OBSTRUCTS_PASSIBILITY?: boolean; OBSTRUCTS_VISION?: boolean};
}>({
    flags: {
        OBSTRUCTS_PASSIBILITY: false,
        OBSTRUCTS_VISION: false,
    },
}) {}

export class Map extends Component({
    width: 0,
    height: 0,
    tiles: [[]],
}) {}

export class Character extends Component({}) {}
export class PlayerControlled extends Component({}) {}

Typed components don’t currently exist in ape-ecs—there’s a pattern that appears to have been first introduced by Mozilla’s ECSY where components are defined as classes with default data stored on the class’s prototype. This isn’t super type-friendly, since generics types can’t access static attributes of classes.

To fix that, I wrote my own types and a helper for ape-ecs called TypedComponent, with a signature like this:

function TypedComponent<TProperties extends DefaultProperties>(
  properties: TProperties
): ClassType<Component<TProperties>> & Constructor<Component & TProperties>;

This makes sure that instances of a subclass of TypedComponent will have fields of those types. An example:

class Position extends TypedComponent<{x: number, y: number, z?: number}>(
    {x: 0, y: 0}
) {};

const p : Position = new Position();
console.log(p.x);
    // (property) x : number
console.log(p.y);
    // (property) y? : number

This also lets entity creation be typesafe, using the method World.createEntityTypesafe:

const thing = world.createEntityTypesafe({
    tags: ['Thingy'],
    c: [{
        type: Position,
        // error for missing .x
        y: 1,
        ex: 1 // error for ex, which doesnt exist on Position
    }]
})

TypedComponents can infer type definitions from default props:

class Position extends TypedComponent(
    {x: 0, y: 0}
) {};
const p : Position = new Position();
console.log(p.x);
    // (property) x : number

but explicit typing allows for finer control and defaults that don’t sacrifice type safety:

type FLAGS = 0 | 1;
class Thing extends Component<{flags: FLAGS}>(
    {flags: 0}
) {};
const t = new Thing();
t.flags = 2;
    // Type '2' is not assignable to type 'FLAGS'

Throughout the rest of this project, I’ll be using TypedComponents.

Decoupling Light

Originally, my algorithm for dungeon generation was spitting out a “baked” dungeon, where light was merged into the cells. In this code from the original pass (link), all layers of the dungeon are merged into a single layer:

const layers = [
    annotateCells(dungeon),
    annotateCells(features),
    addAtmosphericLayer(dungeon)
];

const { flattenedDungeon, flattenedColors } = flattenLayers(layers);

const lightedColors = lightDungeon({
    dungeon: flattenedDungeon,
    // MUTATES
    colors: flattenedColors
});

Notably this didn’t allow for dynamic lighting or for dancing colors. In order to add those, I needed to separate light and color to be passed to the game engine as components.

Rendering with rot.js

Previously, I’ve been rendering using React. Every cell was a <div> with CSS describing color. Switching to rot.js made rendering much simpler, and has allowed for FOV calculation and color mixing.

To make reasoning about rendering simpler, the dungeon generation algorithm will only include colors as {r, g, b} Objects, rather than Colors (defined by the great color library by Qix). rot.js uses any valid CSS property as a color, including rgba for transparency. The generator still mixes colors as Color instances, but unpacks it into {r, g, b} before passing it to the game engine.

First, a look at the final product:

A text-based dungeon showing some walls in various shades of granite, a glowing torch, and a glimpse of a blue lake. Parts of the dungeon are shown in dimmed colors, representing the player's memory. Tiles the player can actively see are illuminated in bright color. Tiles the player has never seen are black.

The room the player (@) is currently in is entirely visible, meaning those tiles are tagged with the Visible component. The torch (orange #) on the left wall is casting a very faint (unnoticable, really, I’ll fix that later 🙃) light. Everything not visible is “memory,” meaning those tiles have the Memory component.

Rendering is split into a few separate systems: Render, PlayerRender, MemoryRender, and Light.

Render is the first pass. The Render system queries for entities that match the following: fromAll(Renderable, Position, Visible).not(Light, Memory, Character), meaning any entity that has the Renderable, Positoin, and Visible components, but not the Light, Memory, or Character components.

These entities are drawn directly onto a rot.js Display like so:

display.draw(
    positionComponent.x,
    positionComponent.y,
    // the letter (@, #, etc.)
    renderableComponent.char,
    toRGBA([
        renderableComponent.fg.r, 
        renderableComponent.fg.g, 
        renderableComponent.fg.b, 
        renderableComponent.fg.alpha
    ]),
    toRGBA([
        renderableComponent.bg.r, 
        renderableComponent.bg.g, 
        renderableComponent.bg.b, 
        renderableComponent.bg.alpha
    ]),
);

Next, MemoryRender draws any entity that matches fromAll(Renderable, Memory, Position).not(Light, Visible) using

const fg = renderableComponent.fg;
const bg = renderableComponent.bg;
this.display.draw(
    positionComponent.x,
    positionComponent.y,
    renderableComponent.char,
    toRGBA(Color.multiply_([fg.r, fg.g, fg.b], [100, 100, 100])),
    toRGBA(Color.multiply_([bg.r, bg.g, bg.b], [100, 100, 100]))
);

This multiplies (darkens) the color of the cell, which is meant to evoke remembering it hazily. Also, note that the Position and DancingColor of components will not be updated when they are not Visible, which means that memory will accurately portray the state of the tile as it was last seen.

The Light system adds the value of Light components to the existing color values at its Position.

FOV

FOV calculation is done with rot.js, which was really great and simple. A FOV is defined using the algorithm and a function for determining whether something is visible, which is a flag defined on an AnnotatedCell:

this.fov = new ROT.FOV.PreciseShadowcasting((x, y) => {
    const playerPosition = player.getOne(Position);
    if (x === playerPosition.x && y === playerPosition.y) {
        return true;
    }
    return !tiles?.[y]?.[x]?.tile.getOne(Tile).flags.OBSTRUCTS_VISION;
}, {});

When the FOV changes (when the player position updates), I recalculate FOV and store the updated values in a map:

fov.compute(player.x, player.y, WIDTH, (x, y, r, visiblity) => {
    if (y >= HEIGHT || x >= WIDTH) {
        return;
    }
    newVisible[`${y},${x}`].visible += 1;
});
newVisible[`${player.y},${player.x}`].visible += 1;
}

Then, by comparing the new visibility to previous visibility, I determine which tiles have gone from unseen to seen and need the Visible component added and Memory component removed, and which cells have gone from seen to unseen and need the opposite.

FOV in action:

An animation of the player moving around the text-based dungeon, their torch light illuminating the dungeon in 360% around them. When they pass through tall plants, their vision is obstructed.

You can see that tall plants block FOV.

Working Example

This dungeon doesn’t have a minimum height, so there’s a good chance for bugs I haven’t encountered in testing.

I’ve tacked the seed on at the bottom, if you run into an erroring dungeon and want to help out, feel tree to make a GitHub issue with the seed.

Not mobile friendly right now, but it will be!