Introduction to Browser-Based Roguelike Development
Roguelike games represent one of the oldest and most enduring genres in gaming, characterized by procedural generation, permanent death, and turn-based gameplay. Thanks to ROT.js, a JavaScript library designed specifically for roguelike development, you can now build these classic games entirely in the browser using familiar web technologies.
Whether you're a seasoned game developer looking to explore browser-based development or a web developer interested in game programming, ROT.js provides the perfect entry point into this fascinating genre. The roguelike genre has seen a renaissance in recent years, with developers appreciating the elegant simplicity of ASCII graphics combined with deep strategic gameplay.
ROT.js handles many of the most challenging aspects of roguelike development, including map generation algorithms, field of view calculations, and input handling, allowing you to focus on gameplay mechanics and creative design. This guide walks you through creating a fully functional roguelike game from scratch, covering everything from basic display setup to advanced features like A* pathfinding algorithms and intelligent enemy AI powered by automation services.
Comprehensive coverage of roguelike game development
Core ROT.js Modules
Display, map generation, FOV computation, pathfinding, and scheduling
Entity System
Flexible architecture for player, enemies, items, and interactive objects
Dungeon Generation
Procedural room creation and intelligent hallway connection algorithms
Field of View
Shadowcasting algorithms for realistic line-of-sight mechanics
Enemy AI
A* pathfinding for intelligent enemy pursuit and navigation
Game Loop
Turn-based architecture with proper scheduling and state management
Setting Up Your Development Environment
Before you can begin building your roguelike game, you need to set up a proper development environment. ROT.js is distributed as an npm package, making it easy to integrate into modern JavaScript or TypeScript projects. For this guide, we'll use ViteJS as our build tool because it provides excellent TypeScript support and a lightning-fast development server.
Installing ROT.js
npm create vite@latest roguelike-tutorial -- --template vanilla-ts
cd roguelike-tutorial
npm install
npm install rot-js
This creates a new project directory with a basic TypeScript setup. Once installation is complete, you can start the development server with npm run dev and access your game at http://localhost:3000.
Your First ROT.js Application
import * as ROT from 'rot-js';
class Engine {
public static readonly WIDTH = 80;
public static readonly HEIGHT = 50;
display: ROT.Display;
constructor() {
this.display = new ROT.Display({
width: Engine.WIDTH,
height: Engine.HEIGHT
});
document.body.appendChild(this.display.getContainer());
this.render();
}
render() {
this.display.draw(40, 25, 'Hello World', '#fff', '#000');
}
}
window.addEventListener('DOMContentLoaded', () => new Engine());
This code creates a simple application that displays "Hello World" text in the center of an 80x50 character grid. The ROT.Display class provides the core rendering functionality for your roguelike, handling the console-style output that defines the genre's aesthetic.
Understanding ROT.js Core Modules
The Display Module
The ROT.Display class serves as the visual foundation of your roguelike game. It manages a grid of character cells, each capable of displaying a character with foreground and background colors. The display uses an HTML5 Canvas element internally, providing excellent performance even for large grids.
When you create a display, you can specify various options to customize its appearance and behavior. The draw method is your primary interface for rendering content to the screen:
// Draw a single character
this.display.draw(x, y, '@', '#fff', '#000');
Map Generation
ROT.js provides several powerful algorithms for procedural map generation. The Digger algorithm creates organic, cave-like structures by simulating a digger moving through solid rock, carving out passages as it goes. The Cellular Automata algorithm generates maps by applying cellular automaton rules, creating natural cave systems with natural-looking boundaries.
Field of View Computation
The PreciseShadowcasting algorithm casts rays from the player's position, calculating which tiles are visible based on the transparency of intervening tiles:
const fov = new ROT.FOV.PreciseShadowcasting((x, y) => {
return this.map[y][x].transparent;
});
fov.compute(player.x, player.y, 8, (x, y, r, visibility) => {
if (visibility === 1) {
this.map[y][x].visible = true;
this.map[y][x].seen = true;
}
});
Pathfinding with A*
The A* pathfinding algorithm finds the shortest path while avoiding obstacles:
const pathfinder = new ROT.Path.AStar(
player.x, player.y,
(x, y) => !this.map[y][x].walkable
);
pathfinder.compute(enemy.x, enemy.y, (x, y) => {
enemy.moveTo(x, y);
});
Building a Generic Entity System
A robust entity system forms the backbone of any roguelike game, providing a flexible foundation for representing the player, enemies, items, and other game objects. Rather than creating separate classes for each entity type, design a generic entity class that can be configured for different purposes.
export class Entity {
x: number;
y: number;
char: string;
fg: string;
bg: string;
name: string;
blocks: boolean;
constructor(
x: number, y: number,
char: string,
fg: string = '#fff',
bg: string = '#000',
name: string = 'Unknown',
blocks: boolean = false
) {
this.x = x;
this.y = y;
this.char = char;
this.fg = fg;
this.bg = bg;
this.name = name;
this.blocks = blocks;
}
move(dx: number, dy: number) {
this.x += dx;
this.y += dy;
}
}
Input Handling Architecture
Clean input handling separates well-designed games from spaghetti code. Rather than scattered event listeners throughout your code, centralize input processing in a dedicated module:
export interface Action {
perform(engine: Engine, entity: Entity): void;
}
export class MovementAction implements Action {
constructor(public dx: number, public dy: number) {}
perform(engine: Engine, entity: Entity) {
const destX = entity.x + this.dx;
const destY = entity.y + this.dy;
if (!engine.gameMap.isWalkable(destX, destY)) return;
entity.move(this.dx, this.dy);
}
}
const MOVE_KEYS = {
'ArrowUp': new MovementAction(0, -1),
'ArrowDown': new MovementAction(0, 1),
'ArrowLeft': new MovementAction(-1, 0),
'ArrowRight': new MovementAction(1, 0),
};
Procedural Dungeon Generation
Creating Rectangular Rooms
The first step in building interesting dungeons is creating rooms that can be placed throughout the map. A rectangular room class encapsulates the room's position, size, and tile data:
class RectangularRoom {
constructor(
public x: number,
public y: number,
public width: number,
public height: number
) {}
get center(): [number, number] {
return [
Math.floor(this.x + this.width / 2),
Math.floor(this.y + this.height / 2)
];
}
intersects(other: RectangularRoom): boolean {
return (
this.x <= other.x + other.width &&
this.x + this.width >= other.x &&
this.y <= other.y + other.height &&
this.y + this.height >= other.y
);
}
}
Connecting Rooms with Generator Functions
Connecting rooms requires algorithms that create paths between points. Generator functions in JavaScript provide an elegant solution for yielding each coordinate along a path:
function* connectRooms(
a: RectangularRoom,
b: RectangularRoom
): Generator<[number, number], void, void> {
let current = a.center;
const end = b.center;
let horizontal = Math.random() < 0.5;
while (current[0] !== end[0] || current[1] !== end[1]) {
const axisIndex = horizontal ? 0 : 1;
const direction = Math.sign(end[axisIndex] - current[axisIndex]);
if (direction !== 0) {
current[axisIndex] += direction;
yield current;
} else {
horizontal = !horizontal;
}
}
}
Complete Generation Algorithm
function generateDungeon(
mapWidth: number, mapHeight: number,
maxRooms: number, minSize: number, maxSize: number
): GameMap {
const dungeon = new GameMap(mapWidth, mapHeight);
const rooms: RectangularRoom[] = [];
for (let i = 0; i < maxRooms; i++) {
const room = new RectangularRoom(
randomInt(0, mapWidth - maxSize),
randomInt(0, mapHeight - maxSize),
randomInt(minSize, maxSize),
randomInt(minSize, maxSize)
);
if (rooms.some(r => r.intersects(room))) continue;
dungeon.addRoom(room);
rooms.push(room);
}
for (let i = 0; i < rooms.length - 1; i++) {
for (const tile of connectRooms(rooms[i], rooms[i + 1])) {
dungeon.setFloor(tile[0], tile[1]);
}
}
return dungeon;
}
Implementing Field of View
Understanding FOV States
The field of view system distinguishes between three states for each tile:
- Unseen: Never explored, rendered as solid color
- Seen: Previously explored but not currently visible, rendered with dark graphics
- Visible: Currently in the player's line of sight, rendered with bright graphics
This distinction creates the sense of exploration that defines roguelike games, where players can only see their immediate surroundings and areas they've previously explored remain visible but dimmed.
Computing Visibility
updateFov(player: Entity): void {
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
this.tiles[y][x].visible = false;
}
}
const fov = new ROT.FOV.PreciseShadowcasting(
(x, y) => this.isTransparent(x, y)
);
fov.compute(player.x, player.y, 8, (x, y, r, visibility) => {
if (visibility > 0) {
this.tiles[y][x].visible = true;
this.tiles[y][x].seen = true;
}
});
}
Rendering Based on Visibility
renderTile(display: ROT.Display, x: number, y: number, tile: Tile): void {
let char = ' ';
let fg = '#000';
let bg = '#000';
if (tile.visible) {
char = tile.light.char;
fg = tile.light.fg;
bg = tile.light.bg;
} else if (tile.seen) {
char = tile.dark.char;
fg = tile.dark.fg;
bg = tile.dark.bg;
}
display.draw(x, y, char, fg, bg);
}
This approach creates the classic roguelike experience where unexplored areas remain mysterious while previously visited areas remain accessible for navigation.
Building the Turn-Based Game Loop
Roguelike games typically use a turn-based system where the player takes an action, then all enemies take their actions. This approach differs from real-time games and requires careful coordination to ensure fair gameplay.
class Engine {
display: ROT.Display;
gameMap: GameMap;
player: Entity;
entities: Entity[];
update(event: KeyboardEvent): void {
this.display.clear();
const action = handleInput(event);
if (action) {
action.perform(this, this.player);
this.updateEntities();
this.gameMap.updateFov(this.player);
}
this.render();
}
private updateEntities(): void {
for (const entity of this.entities) {
if (entity !== this.player && entity.active) {
this.updateEntity(entity);
}
}
}
private updateEntity(entity: Entity): void {
const dx = this.player.x - entity.x;
const dy = this.player.y - entity.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 8) {
const path = new ROT.Path.AStar(
this.player.x, this.player.y,
(x, y) => this.gameMap.isWalkable(x, y)
);
let steps = 0;
path.compute(entity.x, entity.y, (x, y) => {
if (steps === 1) {
entity.x = x;
entity.y = y;
}
steps++;
});
}
}
}
This structure ensures that each game turn consists of the player moving, then all enemies moving in sequence, creating a predictable and fair gameplay experience with intelligent enemy pursuit using A* pathfinding.
User Interface Elements
Message Log
A message log displays important events to the player, such as combat results, discoveries, and game state changes. This UI element is essential for communicating information in text-based games:
export class MessageLog {
messages: { text: string; color: string }[] = [];
add(text: string, color: string = '#fff'): void {
this.messages.push({ text, color });
if (this.messages.length > 8) {
this.messages.shift();
}
}
render(display: ROT.Display, x: number, y: number): void {
for (let i = 0; i < this.messages.length; i++) {
const msg = this.messages[i];
display.drawText(x, y + i, msg.text, { fg: msg.color });
}
}
}
Status Display
Status bars display important player information like health, experience, and other attributes:
renderStatus(display: ROT.Display): void {
const healthPercent = this.player.hp / this.player.maxHp;
const healthBar = '[' + '='.repeat(Math.floor(healthPercent * 20)) + ']';
display.drawText(1, 1, `HP: ${this.player.hp}/${this.player.maxHp} ${healthBar}`);
display.drawText(1, 2, `Level: ${this.player.level}`);
display.drawText(1, 3, `Dungeon Depth: ${this.dungeonLevel}`);
}
Combine status displays with the message log and game map to create a complete user interface for your roguelike game.
Advanced Topics and Best Practices
Performance Optimization
As your game grows, performance can become an issue. Several strategies help maintain smooth gameplay:
- Only render visible tiles using the FOV calculation to avoid unnecessary drawing operations
- Reuse objects during the game loop instead of creating new ones
- Consider object pooling for frequently created items like projectiles
Saving and Loading
Most roguelikes allow players to save and resume their progress. JSON serialization provides a straightforward way to persist game state:
interface GameState {
player: SerializedEntity;
dungeon: SerializedDungeon;
messages: { text: string; color: string }[];
}
function saveGame(engine: Engine): string {
return JSON.stringify({
player: serializeEntity(engine.player),
dungeon: serializeDungeon(engine.gameMap),
messages: [...engine.log.messages]
});
}
Extending Your Game
Once you've built a complete roguelike, consider adding:
- Inventory systems: Collect and use items strategically
- Multiple dungeon levels: Descend deeper for greater challenges
- Combat mechanics: Health, damage, and death systems
- Procedural content: Random items, enemies, and special rooms
- Sound and music: Atmospheric audio enhancements
Each of these extensions builds upon the solid foundation you've created, adding depth and variety to your roguelike game. For more on JavaScript game development, explore our comprehensive guides and resources. Modern AI automation can also enhance procedural generation with intelligent content creation.
Summary
Building a roguelike game with ROT.js combines the elegance of classic ASCII graphics with the accessibility of modern web development. The library handles complex algorithms for map generation, field of view calculation, and pathfinding, allowing you to focus on gameplay mechanics and creative design.
This guide has covered:
- Setting up a development environment with Vite and TypeScript
- Understanding ROT.js core modules: Display, Map, FOV, Pathfinding, RNG
- Building a flexible entity system for players, enemies, and items
- Implementing procedural dungeon generation with rooms and hallways
- Creating realistic field of view with shadowcasting
- Coordinating a turn-based game loop with enemy AI
- Adding user interface elements like message logs and status displays
The roguelike genre offers nearly infinite possibilities for creative expression. Whether you want to create a traditional dungeon crawler, an innovative puzzle game, or an experimental narrative experience, the techniques covered here provide the tools you need to bring your vision to life. Start building your roguelike today and join the long tradition of developers who have fallen in love with this elegant and enduring game genre.
For teams looking to build interactive browser-based games or web applications, our web development services can help bring your creative vision to life with modern technologies and best practices.
Frequently Asked Questions
Sources
- ROT.js Manual - Official library documentation
- RogueBasin: ROT.js Tutorial - Roguelike development wiki
- LogRocket: Building a Roguelike Game with ROT.js - Comprehensive tutorial
- Nick Klepinger's Blog: ROT.js Tutorial Series - Extensive TypeScript tutorial series