Quantcast
Channel: Envato Tuts+ Game Development
Viewing all articles
Browse latest Browse all 728

How to Make Your First Roguelike

$
0
0

Roguelikes have been in the spotlight recently, with games like Dungeons of Dredmor, Spelunky, The Binding of Isaac, and FTL reaching wide audiences and receiving critical acclaim. Long enjoyed by hardcore players in a tiny niche, roguelike elements in various combinations now help bring more depth and replayability to many existing genres.

Wayfarer, a 3D roguelike currently in development.
Wayfarer, a 3D roguelike currently in development.

In this tutorial, you will learn how to make a traditional roguelike using JavaScript and the HTML 5 game engine Phaser. By the end, you will have a fully-functional simple roguelike game, playable in your browser! (For our purposes a traditional roguelike is defined as a single-player, randomized, turn-based dungeon-crawler with permadeath.)

Click to play the game.
Click to play the game.

Note: Although the code in this tutorial uses JavaScript, HTML, and Phaser, you should be able to use the same technique and concepts in almost any other coding language and game engine.


Getting Ready

For this tutorial, you will need a text editor and a browser. I use Notepad++, and I prefer Google Chrome for its extensive developer tools, but the workflow will be pretty much the same with any text editor and browser you choose.

You should then download the source files and start with the init folder; this contains Phaser and the basic HTML and JS files for our game. We will write our game code in the currently empty rl.js file.

The index.html file simply loads Phaser and our aforementioned game code file:

<!DOCTYPE html><head><title>roguelike tutorial</title><script src="phaser.min.js"></script><script src="rl.js"></script></head></html>

Initialization and Definitions

For the time being, we'll use ASCII graphics for our roguelike—in the future, we could replace these with bitmap graphics, but for now, using simple ASCII makes our lives easier.

Let's define some constants for the font size, the dimensions of our map (that is, the level), and how many actors spawn in it:

        // font size
        var FONT = 32;

        // map dimensions
        var ROWS = 10;
        var COLS = 15;

        // number of actors per level, including player
        var ACTORS = 10;

Let's also initialize Phaser and listen for keyboard key-up events, as we will be creating a turn based game and will want to act once for every key stroke:

// initialize phaser, call create() once done
var game = new Phaser.Game(COLS * FONT * 0.6, ROWS * FONT, Phaser.AUTO, null, {
        create: create
});

function create() {
        // init keyboard commands
        game.input.keyboard.addCallbacks(null, null, onKeyUp);
}

function onKeyUp(event) {
        switch (event.keyCode) {
                case Keyboard.LEFT:

                case Keyboard.RIGHT:

                case Keyboard.UP:

                case Keyboard.DOWN:

        }
}

Since default monospace fonts tend to be about 60% as wide as they are high, we've initialized the canvas size to be 0.6 * the font size * the number of columns. We're also telling Phaser that it should call our create() function immediately after it's finished initialising, at which point we initialize the keyboard controls.

You can view the game so far here—not that there's much to see!


The Map

The tile map represents our play area: a discrete (as opposed to continuous) 2D array of tiles, or cells, each represented by an ASCII character that can signify either a wall (#: blocks movement) or floor (.: doesn't block movement):

        // the structure of the map
        var map;

Let's use the simplest form of procedural generation to create our maps: randomly deciding which cell should contain a wall and which a floor:

function initMap() {
        // create a new random map
        map = [];
        for (var y = 0; y < ROWS; y++) {
                var newRow = [];
                for (var x = 0; x < COLS; x++) {
                     if (Math.random() > 0.8)
                        newRow.push('#');
                    else
                        newRow.push('.');
                }
                map.push(newRow);
        }
}

This should give us a map where 80% of the cells are walls and the rest are floors.

We initialize the new map for our game in the create() function, immediately after setting up the keyboard event listeners:

function create() {
        // init keyboard commands
        game.input.keyboard.addCallbacks(null, null, onKeyUp);

        // initialize map
        initMap();
}

You can view the demo here—although, again, there's nothing to see, as we haven't rendered the map yet.


The Screen

It's time to draw our map! Our screen will be a 2D array of text elements, each containing a single character:

        // the ascii display, as a 2d array of characters
        var screen;

Drawing the map will fill in the screen's content with the map's values, since both are simple ASCII characters:

        function drawMap() {
                for (y in 0...ROWS)
                        for (x in 0...COLS)
                                screen[y][x].content = map[y][x];
        }

Finally, before we draw the map we have to initialize the screen. We go back to our create() function:

        function create() {
                // init keyboard commands
                game.input.keyboard.addCallbacks(null, null, onKeyUp);

                // initialize map
                initMap();

                // initialize screen
                screen = [];
                for (y in 0...ROWS) {
                        var newRow = [];
                        screen.push(newRow);
                        for (x in 0...COLS)
                                newRow.push( initCell('', x, y) );
                }
                drawMap();
        }

        function initCell(char:String, x:Int, y:Int):phaser.Text {
                // add a single cell in a given position to the ascii display
                var style = { font: FONT + "px monospace", fill:"#fff"};
                return game.add.text(FONT*0.6*x, FONT*y, char, style);
        }

You should now see a random map displayed when you run the project.

Click to view the game so far.
Click to view the game so far.

Actors

Next in line are the actors: our player character, and the enemies they must defeat. Each actor will be an object with three fields: x and y for its location in the map, and hp for its hit points.

We keep all actors in the actorList array (the first element of which is the player). We also keep an associative array with the actors' locations as keys for quick searching, so that we don't have to iterate over the entire actor list to find which actor occupies a certain location; this will help us when we code the movement and combat.

// a list of all actors; 0 is the player
var player;
var actorList;
var livingEnemies;

// points to each actor in its position, for quick searching
var actorMap;

We create all our actors and assign a random free position in the map to each:

function randomInt(max) {
   return Math.floor(Math.random() * max);
}

function initActors() {
        // create actors at random locations
        actorList = [];
        actorMap = {};
        for (var e=0; e<ACTORS; e++) {
                // create new actor
                var actor = { x:0, y:0, hp:e == 0?3:1 };
                do {
                        // pick a random position that is both a floor and not occupied
                        actor.y=randomInt(ROWS);
                        actor.x=randomInt(COLS);
                } while ( map[actor.y][actor.x] == '#' || actorMap[actor.y + "_" + actor.x] != null );

                // add references to the actor to the actors list & map
                actorMap[actor.y + "_" + actor.x]= actor;
                actorList.push(actor);
        }

        // the player is the first actor in the list
        player = actorList[0];
        livingEnemies = ACTORS-1;
}

It's time to show the actors! We're going to draw all the enemies as e and the player character as its number of hitpoints:

function drawActors() {
        for (var a in actorList) {
                if (actorList[a].hp > 0)
                        screen[actorList[a].y][actorList[a].x].content = a == 0?''+player.hp:'e';
        }
}

We make use of the functions we just wrote to initialize and draw all actors in our create() function:

function create() {
	...
	// initialize actors
	initActors();
	...
	drawActors();
}

We can now see our player character and enemies spread out in the level!

Click to view the game so far.
Click to view the game so far.

Blocking and Walkable Tiles

We need to make sure that our actors aren't running off the screen and through walls, so let's add this simple check to see in which directions a given actor can walk:

function canGo(actor,dir) {
	return 	actor.x+dir.x >= 0 &&
		actor.x+dir.x <= COLS - 1 &&
                actor.y+dir.y >= 0 &&
		actor.y+dir.y <= ROWS - 1 &&
		map[actor.y+dir.y][actor.x +dir.x] == '.';
}

Movement and Combat

We've finally arrived at some interaction: movement and combat! Since, in classic roguelikes, the basic attack is triggered by moving into another actor, we handle both of these at the same spot, our moveTo() function, which takes an actor and a direction (the direction is the desired difference in x and y to the position the actor steps in):

function moveTo(actor, dir) {
        // check if actor can move in the given direction
        if (!canGo(actor,dir))
                return false;

        // moves actor to the new location
        var newKey = (actor.y + dir.y) +'_' + (actor.x + dir.x);
        // if the destination tile has an actor in it
        if (actorMap[newKey] != null) {
                //decrement hitpoints of the actor at the destination tile
                var victim = actorMap[newKey];
                victim.hp--;

                // if it's dead remove its reference
                if (victim.hp == 0) {
                        actorMap[newKey]= null;
                        actorList[actorList.indexOf(victim)]=null;
                        if(victim!=player) {
                                livingEnemies--;
                                if (livingEnemies == 0) {
                                        // victory message
                                        var victory = game.add.text(game.world.centerX, game.world.centerY, 'Victory!\nCtrl+r to restart', { fill : '#2e2', align: "center" } );
                                        victory.anchor.setTo(0.5,0.5);
                                }
                        }
                }
        } else {
                // remove reference to the actor's old position
                actorMap[actor.y + '_' + actor.x]= null;

                // update position
                actor.y+=dir.y;
                actor.x+=dir.x;

                // add reference to the actor's new position
                actorMap[actor.y + '_' + actor.x]=actor;
        }
        return true;
}

Basically:

  1. We make sure the actor is trying to move into a valid position.
  2. If there is another actor in that position, we attack it (and kill it if its HP count reaches 0).
  3. If there isn't another actor in the new position, we move there.

Notice that we also show a simple victory message once the last enemy has been killed, and return false or true depending on whether or not we managed to perform a valid move.

Now, let's go back to our onKeyUp() function and alter it so that, every time the user presses a key, we erase the previous actor's positions from the screen (by drawing the map on top), move the player character to the new location, and then redraw the actors:

function onKeyUp(event) {
        // draw map to overwrite previous actors positions
        drawMap();

        // act on player input
        var acted = false;
        switch (event.keyCode) {
                case Phaser.Keyboard.LEFT:
                        acted = moveTo(player, {x:-1, y:0});
                        break;

                case Phaser.Keyboard.RIGHT:
                        acted = moveTo(player,{x:1, y:0});
                        break;

                case Phaser.Keyboard.UP:
                        acted = moveTo(player, {x:0, y:-1});
                        break;

                case Phaser.Keyboard.DOWN:
                        acted = moveTo(player, {x:0, y:1});
                        break;
        }

        // draw actors in new positions
        drawActors();
}

We will soon use the acted variable to know if the enemies should act after each player input.

Click to view the game so far.
Click to view the game so far.

Basic Artificial Intelligence

Now that our player character is moving and attacking, let's even the odds by making the enemies act according to very simple path finding as long as the player is six steps or fewer from them. (If the player is further away, the enemy walks randomly.)

Notice that our attack code doesn't care who the actor is attacking; this means that, if you align them just right, the enemies will attack each other while trying to pursue the player character, Doom-style!

function aiAct(actor) {
        var directions = [ { x: -1, y:0 }, { x:1, y:0 }, { x:0, y: -1 }, { x:0, y:1 } ];
        var dx = player.x - actor.x;
        var dy = player.y - actor.y;

        // if player is far away, walk randomly
        if (Math.abs(dx) + Math.abs(dy) > 6)
                // try to walk in random directions until you succeed once
                while (!moveTo(actor, directions[randomInt(directions.length)])) { };

        // otherwise walk towards player
        if (Math.abs(dx) > Math.abs(dy)) {
                if (dx < 0) {
                        // left
                        moveTo(actor, directions[0]);
                } else {
                        // right
                        moveTo(actor, directions[1]);
                }
        } else {
                if (dy < 0) {
                        // up
                        moveTo(actor, directions[2]);
                } else {
                        // down
                        moveTo(actor, directions[3]);
                }
        }
        if (player.hp < 1) {
                // game over message
                var gameOver = game.add.text(game.world.centerX, game.world.centerY, 'Game Over\nCtrl+r to restart', { fill : '#e22', align: "center" } );
                gameOver.anchor.setTo(0.5,0.5);
        }
}

We've also added a game over message, which is shown if one of the enemies kills the player.

Now all that's left to do is make the enemies act every time the player moves, which requires adding the following to the end of our onKeyUp() functions, right before drawing the actors in their new position:

function onKeyUp(event) {
        ...
        // enemies act every time the player does
        if (acted)
                for (var enemy in actorList) {
                        // skip the player
                        if(enemy==0)
                                continue;

                        var e = actorList[enemy];
                        if (e != null)
                                aiAct(e);
                }

        // draw actors in new positions
        drawActors();
}
Click to view the game so far.
Click to view the game so far.

Bonus: Haxe Version

I originally wrote this tutorial in a Haxe, a great multi-platform language that compiles to JavaScript (among other languages). Although I translated the version above by hand as to make sure we get idiosyncratic JavaScript, if, like me, you prefer Haxe to JavaScript, you can find the Haxe version in the haxe folder of the source download.

You need to first install the haxe compiler and can use whatever text editor you wish and compile the haxe code by calling haxe build.hxml or double-clicking the build.hxml file. I also included a FlashDevelop project if you prefer a nice IDE to a text editor and command line; just open rl.hxproj and press F5 to run.


Summary

That's it! We now have a complete simple roguelike, with random map generation, movement, combat, AI and both win and lose conditions.

Here are some ideas for new features you can add to your game:

  • multiple levels
  • power ups
  • inventory
  • consumables
  • equipment

Enjoy!


Viewing all articles
Browse latest Browse all 728

Trending Articles