Making a randomly generated isometric dungeon in Gamemaker Studio

Before I get started with this, I would like to state that this project is a combination of the Heartbeast randomly generated dungeon tutorial and the Shaun Spalding Isometric tutorial. I have done the leg work that allows these two different tutorials to work together and I am writing this to have the information in 1 consolidated place.

Both Heartbeast and Shaun are better game developers and teachers than I am. I would highly suggest that you go and watch their tutorials because if you were to do that you would be able to do this all on your own. If you feel like you want to continue with me then we can get started.

The start of this tutorial will seem very familiar if you have followed the Heartbeast tutorial, or if you read my summary of getting this same script to work in Godot.

In a new Gamemaker Studio 2 project we are going to import 2 sprite sheets, a top down sprite sheet that we will use to make a tileset and an isometric sprite sheet that we will convert into frames. My top down sprites have a resolution of 64 by 64 and my isometric sprites have a resolution of 64 by 96. The isometric sprite should have an origin of top center. (You can download the sprites that I used for this tutorial here.)

Sprites and Tileset has been created

Next we can make 2 objects, a level object and a render object. The create event of the level object is what will execute the code to generate the level and the render object is going to draw (render) the isometric level in its draw event.

Once these objects are created we can make a new script to declare the macros that we are going to be using. We will need to make a macro for the width and height of our map when it is top down and the width and height of the isometric tiles as well as references to when a cell is void, floor or wall

// Macros
#macro CELL_WIDTH 64
#macro CELL_HEIGHT 64

#macro TILE_W 64
#macro TILE_H 32

#macro FLOOR -5
#macro WALL -6
#macro VOID -7

With these initial macros taken care of we can go and code the Drunken Walk algorithm in the level object create event. If you remember how we did it the last time, we start by getting a random seed, setting the width and height of a DS_GRID, creating the DS_GRID, filling the grid with void and creating our controller. We are also need a reference to the tilemap layer in the room and a variable to store our odds for changing direction.

// obj_level create event

// Get random seed
randomize();

// Reference to Tilemap Layer
var tile_map_layer = layer_tilemap_get_id("Map");

// Set Grid width and height
global.grid_width = room_width div CELL_WIDTH;
global.grid_height = room_height div CELL_HEIGHT;

// Create DS_GRID
global.grid = ds_grid_create(global.grid_width, global.grid_height);

// FILL Grid with Void
ds_grid_set_region(global.grid, 0, 0, global.grid_width, global.grid_height, VOID);

// Create the controller
var controller_x = global.grid_width div 2;
var controller_y = global.grid_height div 2;
var controller_direction = irandom(3);
var direction_odds = 1;

Next we run a loop several hundred times times to carve out the floor from all of the void space. This is done by taking the current X and Y position of our controller, flagging that location as a floor, then we check our odds to see if we change our direction. Finally we update our controller location by moving it towards our direction. The direction is a random int from 0-3 which we multiply by 90 to get a value of 0, 90, 180 or 270 degrees. Don’t forget to clamp your controller to stay inside your grid or your game will crash.

// Assign floor tiles
repeat (400)
{
// Set the current position to floor
global.grid[# controller_x, controller_y] = FLOOR;
// Randomize the direction
if (irandom(direction_odds) == direction_odds)
    controller_direction = irandom(3);

// Move the controller
var x_direction = lengthdir_x(1, controller_direction * 90);
var y_direction = lengthdir_y(1, controller_direction * 90);
controller_x += x_direction;
controller_y += y_direction;

// Clamp the controller to the grid
if (controller_x < 2 || controller_x >= global.grid_width -2)
    controller_x += -x_direction * 2;
if (controller_y < 2 || controller_y >= global.grid_height -2)
    controller_y += -y_direction * 2;
}

After the floor has been created, we can run a nested for loop through the DS_GRID and assign wall tiles. Walls are assigned by checking if a floor tile is beside a void tile and changing that void to wall; however, since this is an isometric game, you only need to assign walls if the void tile is above or to the left of a floor, leaving the rest alone.

// Flag Walls
for (var yy = 1; yy < global.grid_height - 1; yy++)
{
for (var xx = 1; xx < global.grid_width - 1; xx++)
{
if (global.grid[# xx, yy] == FLOOR)
{
if (global.grid[# xx, yy-1] == VOID)
global.grid[# xx, yy-1] = WALL;
if (global.grid[# xx-1, yy] == VOID)
global.grid[# xx-1, yy] = WALL;
}
}
}

The final step in this process is to draw the tilemap to the screen. This is done with another nested for loop through the DS_GRID and drawing the co-responding tile. We don’t need to worry about running so many nested for loops here, this is the create event and it is only run once.

// Draw the level
for (var yy = 1; yy < global.grid_height - 1; yy++)
{
for (var xx = 1; xx < global.grid_width - 1; xx++)
{
if (global.grid[# xx, yy] == FLOOR)
tilemap_set(tile_map_layer, 1, xx, yy);
if (global.grid[# xx, yy] == WALL)
tilemap_set(tile_map_layer, 2, xx, yy);
}
}

If you were to take your level object, put it into a room, double the room’s size from the starting value and create a tileset layer called “Map”, you should get output similar to what I have.

Drunken Walk with walls

With our level object creating new levels at execution, we are ready to start rendering them in an isometric perspective. First we need to add an enum to our macros script, which will store which isometric sprite to draw and its height data.

enum TILE
{
SPRITE = 0,
Z = 1
}

We can now create the render object and start working on it’s create event. We can start by disabling the Map tileset layer in the room so that it doesn’t render at the same time as the isometric level. Then create another DS_GRID to be able to store all of the isometric data and run another nested for loop to loop through all of the tiles and store their data in the new grid.

// obj_render Create Event

// Set the tilemap to not be visible
layer_set_visible("Map", false);

// Create a DS Grid to store tile information
global.theMap = ds_grid_create(global.grid_width, global.grid_height);

// Get Tileset ID
var tileMap = layer_tilemap_get_id("Map");

// Start nested for loop
for (var tx = 0; tx < global.grid_width; tx++)
{
for (var ty = 0; ty < global.grid_height; ty++)
{

// Make local variable for the cell I am on
var  tile_map_data = tilemap_get(tileMap, tx, ty);
		
// Format : [Sprite, Z]
var thistile = [-1, 0];
		
// Assign the sprite
thistile[TILE.SPRITE] = tile_map_data;
		
// Assign the Z Height for walls
if (global.grid[# tx, ty] == WALL)
thistile[TILE.Z] = -40;
else
thistile[TILE.Z] = 0;
		
// Assign this information to the new DS_GRID
global.theMap[# tx, ty] = thistile;
}
}

For the next part, we are going to need 4 of Shaun Spalding’s scripts. tile_to_screen_x, tile_to_screen_y, screen_to_tile_x, screen_to_tile_y. The tile_to_screen scripts are able to take a location from our new DS_GRID and render it to the screen. The screen_to_tile scripts are able to take an x and y position in the room and determine which cell in the DS_GRID they are on. Please go watch his video to see the code and to get an explanation of how they work.

Once those 4 scripts are in your possession we can proceed by rendering the level in an isometric view. This work is going to be done in the draw event of our obj_render object. What we do is create a collection of temporary variables to store our tiles data, screen position, which sprite to render and its height. We loop through the DS_GRID and draw it to the screen.

// obj_render draw event

// Create local variables for the tileData, X Y and Z position as well as the sprite
var tileData, screenX, screenY, tileIndex, tileZ;

// Start nested for loop for the ground
for (var tx = 0; tx < global.grid_width; tx++)
{
for (var ty = 0; ty < global.grid_height; ty++)
{
// Assign cell data to local variables
tileData = global.theMap[# tx, ty];
screenX = tile_to_screen_x(tx, ty);
screenY = tile_to_screen_y(tx, ty);
tileIndex = tileData[TILE.SPRITE];
tileZ = tileData[TILE.Z];
		
// Draw the floor
if (tileIndex != 0 && global.grid[# tx, ty] == FLOOR)
draw_sprite(spr_isometric, tileIndex-1, screenX, screenY + tileZ);

// Draw the walls
if (tileIndex != 0 && global.grid[# tx, ty] == WALL)
draw_sprite(spr_isometric, tileIndex-1, screenX, screenY + tileZ);		
}
}

To get the render object working, you can add 1 line to the very end of the obj_level create event to create this object on the instances layer.

instance_create_layer(x, y, "Instances", obj_render);

When you run the game you should get something similar to what I have.

Randomly Generated Isometric Levels

These levels are pretty great but we need some way to get our player inside of them, which isn’t as tricky as it would seem. First you can go into your room and enable viewports, and set your camera to whatever resolution fits your game and make sure it is set to follow your player. I have my camera resolution set to 512 by 288 and the viewport at 1024 by 576. Then inside the obj_level create event we can run a piece of code to spawn the player in the middle of the room.

instance_create_layer(room_width div 2, room_height div 2, "Instances", obj_player);

This line of code will not put the player inside of your isometric map, the player will be created very far away. If we use the tile_to_screen functions we can update our player’s position to put him directly in the middle of the isometric map.

// obj_player create event
x = tile_to_screen_x(x div TILE_W, y div TILE_W);
x = tile_to_screen_y(x div TILE_W, y div TILE_W);

Now that the player is showing up inside of our isometric level, we need a way to be able to get him to be able to collide with the walls. Unfortunately our walls are not game objects, and they do not have hit boxes.

Heartbeast did a tutorial about collisions with tiles, we can modify that script to get the player to collide with the walls. We are going to need to use the screen_to_tile functions to be able to do that.

Inside of this updated grid_place_meeting script we pass in the player’s x and y position + an hspd and vspd modification. The current X and Y positions are stored in temporary variables so that we can move back, then we add the hspd and vspd to the x and y position. After the hspd and vspd have been applied we use the screen_to_tile functions to see if the player is in a wall. Then we move the player back to their original x and y position and return if there was a collision.

// Assign argument to temp variables
var xx = argument[0];
var yy = argument[1];

// Remember current position
var xp = x;
var yp = y;

// Update position for bounding box calculations
x = xx;
y = yy;

// Check for X meeting
//var x_meeting = (global.grid[# bbox_right div CELL_WIDTH, bbox_top div CELL_HEIGHT]!= FLOOR) || 
//				(global.grid[# bbox_left div CELL_WIDTH, bbox_top div CELL_HEIGHT] != FLOOR);

var x_meeting = (global.grid[# screen_to_tile_x(x + 8, y), screen_to_tile_y(x + 8, y)] != FLOOR) ||
				(global.grid[# screen_to_tile_x(x, y - 4), screen_to_tile_y(x, y - 4)] != FLOOR);
				
// Check for Y meeting
var y_meeting = (global.grid[# screen_to_tile_x(x - 8, y), screen_to_tile_y(x - 8, y)] != FLOOR) ||
				(global.grid[# screen_to_tile_x(x, y + 4), screen_to_tile_y(x, y + 4)] != FLOOR);

// Check for Center Meeting
var center_meeting = global.grid[# screen_to_tile_x(xx, yy), screen_to_tile_y(xx, yy)] != FLOOR;

// Move back
x = xp;
y = yp;

// Return true or false;
return (x_meeting || y_meeting || center_meeting);

Using this updated grid_place_meeting script we can now check if a player object has collided with the isometric walls.

Working grid collisions in an isometric map

There we have it, randomly generated isometric dungeons and working grid based collisions.

Until Next Time
– Steven

Converting Heartbeast’s Random Walk to Godot Part 1

One of my colleagues has been requesting that I make the switch to Godot from Gamemaker Studio to develop my games. One key point to our discussions has been about the video of Heartbeast saying that his favorite engine to use is Godot (also, you can use godot in Linux).

I have been reluctant to make the switch because I find the ease of use with Gamemaker Studio very appealing and my overall comfort level with it is high; however, I relented and agreed to try out Godot for the next game that I am working on. Depending on how well this goes I may switch over to Godot permanently.

If I am going to switch over, some of my commonly used tricks would need to come over with me, and one of the first things I would need to do is figure out how to rebuild Heartbeast‘s Drunken Walk algorithm in Godot.

For those who are not familiar with how this algorithm works, Heartbeast creates a DS_Grid, fills it with a void value, and then has a “controller” (just 2 variables that hold an x and y value) move through the DS_Grid, randomly, and assign the value of floor to its coordinates. For this conversion to be considered successful I will need to match this functionality. So, lets open up a new Godot project and see what we can do.

By default the Godot editor will be in 3d, you can switch over to 2d by clicking on 2D at the top of the editor. The png file, that we want to use for our tileset, can be dragged into the asset library at the bottom left of the screen. Once your editor looks like mine, then we are able to proceed. (If you don’t want to make your own sprites, you can download the sprites I will be using here.)

Next we will need to add a node to the scene. Since our scene is a 2d scene, it makes sense to add a 2d node. After your root node has been added we can add a tilemap as a child to the root node. The tilemap is a new feature to Godot and it will provide us the same functionality that the DS_Grid does in Gamemaker Studio, and a little bit more.

Once the tilemap has been added to the scene, we can edit it with the inspector, on the right hand side of the screen, while it is selected. A new tilemap will start with the tileset field being empty, by clicking on the drop down menu we can create a new tileset, and we can edit the tileset by clicking on the drop down menu again (I found this part confusing at first).

Once you click edit tile map, a new window will be displayed at the bottom of the screen. With this window we can assign a texture (sprite) to the tileset and after our texture has been assigned we can select which part of the sprite represents what tile by clicking on “New Single Tile”.

A lot more work in the editor than I am used to in GM:S

After our tiles have been created, we can specify which tiles will be used for collision. My sprite has 3 tiles, a void, ground and wall, so I assigned collision to void and wall, you can do whatever your project requires. We also need to set the tile size in the inspector, my tiles are 32 * 32. Don’t forget to save your tilemap by clicking the save button in the inspector at the top right hand side.

Here is the full process of building the tileset.

This is most of the work that we need to finish in the editor, the rest of the work is going to be coding in Gdscript. To attach a script to the tilemap we can right click the tilemap and select “Attach Script”. After the script has been attached, the editor will open and we will be ready to do some coding.

Finally some coding

The script that was created comes with 2 pre-built functions, the _ready() function and the _process(delta) function. These are comparable to the create and step events from Gamemaker Studio. Most of our work is going to be inside of the _ready() function, but before we do that we need to write some code at the very top of the script.

Like in Heartbeast’s script we first create some constants to reference the tiles in our tile map. Your values may be different than mine depending on the order that you created them. I made mine in order from left to right on the sprite so 0, 1, 2 will be void, ground and wall respectively. We can also use a line of code to add a field to the editor that allows us to set how many tiles will be in the room. Instead of having a separate x and y values, like in Gamemaker, we can just use a Vector2.

export (Vector2) var room_size;
const VOID = 0;
const GROUND = 1;
const WALL = 2;

In the _ready() function we can create our controller and have it start in the middle of the tilemap. We also need to get our first random direction, and set how frequently it will change direction. Like the room size, our controller can also be a Vector2, the direction can still be an int from 0-3 and the odds can still be an int from 0-1 giving us a 50% chance of changing directions. You will also need to call the randomize function so that your map gets a new seed each time it executes.

randomize();
var controller = room_size / 2;
var direction = randi() % 4;
var odds = randi() % 2;

The final part of this script is to use a loop to move the controller in a random direction. This looks pretty similar to how it is done with Gamemaker Studio. We create an if statement to check if we need to switch direction, we multiply the direction by 90 to give us a value in degrees, from 0-360, and then move the cursor in our random direction. We are also going to clamp the controller so that it doesn’t go outside of the tilemap.

for i in 400:
     set_cellv(controller, GROUND);
     var vdir = Vector2.RIGHT.rotated(deg2rad(direction * 90));
     if odds == 1:
          direction = randi() % 4;
     controller += vdir;
     controller.x = clamp(controller.x, 0, room_size.x - 1);
     controller.y = clamp(controller.y, 0, room_size.y - 1);
     odds = randi() % 2;

With our coding done we can go back to the editor and set how large the room will be in tiles with the room_size variable that will now display. The sprites are 32 by 32 so we can make the cell size 32 by 32 and we can make the room size 32 by 32. This gives our game a width and height of 32 cells and each cell is 32 by 32 pixels.

The finished inspector of the tilemap

Before you run the game you need to make sure that your camera is big enough to see the whole map (this tutorial doesn’t cover making a player or a moving camera). To adjust the map size you can click on Project -> Project Settings -> Display -> Window and adjust the resolution so that it will fit your whole map (1024 * 1024). Once that is finished you can run the scene and you should hopefully see something like this.

I have a Drunken Walk map

Because this is being done in Godot, and GDScript isn’t as broken as GML, we can copy the contents of the _ready() function and make our own function called _generate_map() and inside of _process(delta) we can clear the tilemap and generate a new map with a keypress and you can see the different maps you can get.

if (Input.is_action_pressed("ui_select")):
     clear();
     _generate_map();

Changing the odds variable will give us different kinds of maps. Having the odds variable as odds = randi() % 2; gives us a value between 0 and 1, giving us a 50/50 chance of changing direction. Which results in maps that have larger rooms. Lowering your odds will result in maps with longer and longer hallways and less rooms. Here is an example with odds = randi() % 5 (you need to change your if statement to if odds == 4: for this to work)

The script isn’t 100% finished yet. Walls are not yet being spawned in, and there are some other missing features, but this blog post / tutorial is already over 1000 words, so I suppose I will have to come back and write another post to finish the script.

I would like to thank Rkoopah for helping me out while I am learning how to use Godot.

You can click here to read part 2
You can click here to read part 3

Until Next Time
– Steven