Experimenting With Random Isometric Map Generation

Patrick Ramser
11 min readAug 7, 2022

--

I decided to start working on a small, indie game in my spare time that combines elements of my favorite games: rogue-like, random maps and isometric combat. The initial idea was for some hybrid of Spelunky-like dungeon exploration and combat akin to Final Fantasy Tactics. They’re two of my favorite games of all-time; why not combine elements of your favorite games, if any?

Art generated by Midjourney using a prompt related to the article written.

Trouble is isometric games largely haven’t made the plunge into the world of random generation. With good reason. It can be tough to create interesting combat scenarios or a sense of exploration using a random map that’s largely visible using an isometric perspective. Terrain is — for me — a crucial element when thinking about strategy in something akin to Final Fantasy Tactics.

I figured why not try to work something up and see what happens. I’ll walk you through two methods I researched for map generation in my little prototype and the method I ultimately decided on using. Keep in mind, my idea for these maps long-term is to use a fog of war for the player once done. This should help retain some sense of danger and mystery by hiding large portions of these maps by the time whatever version of a game I have here is complete (or not).

Let’s get to it.

The Spelunky Method*

*or generating a map using preconfigured rooms that includes a critical path from an entrance to an exit

This method of map generation leverages a pathfinding equation which creates an entrance and a path to an exit, followed by populating the remaining rooms as filler rooms. We can visualize this in a 2D platformer type of game by taking a screenshot of a Spelunky map and marking off the critical path as well as nodes that are considered filler rooms.

Example from a Feb 17, 2014 article for Game Developer written by Jamie Smith.

Every number in the above screenshot that isn’t 0 is baked into a route corresponding to this maps bounds. In this case, 4 x 4. The route starts randomly at the last place of the top row and it drops, crawls across the x-axis, and snakes it’s way through the map until it settles on the last position of the bottom row for the exit. After that route is created, the 0’s are placed into the same data structure (array, map, whatever) as the rest of the rooms in whatever empty spaces are available.

To address the route itself there will be some slightly intricate decisions in the initial pathfinding, but it’s nothing impossible. If you imagine yourself as the node navigating the while-loop below you’ll have a better sense of how this works in real time when looking at the screenshot.

*Note that Spelunky goes from the top of the map to the bottom when generating rooms, but the function here goes from bottom (y = 0) to top (y = 3) for slightly easier reading. Just invert the y-axis it if you want the same behavior.

void createRoute(int width, int height)
{
const Y_BOUNDS = height - 1;
const X_BOUNDS = width - 1;
int currentY = 0;
int currentX = Random.Range(0, width);
int newDirection = 0; // 0 random, 1 left, 2 right, 3 down
addToMap(currentX, currentY, 1); // hallway while(currentY < height)
{
if(newDirection == 0) // random
{
newDirection = randomDirection(new [] { 1, 1, 2, 2, 3 });
}
if(newDirection == 1) // left
{
if(currentX > 0) // didn't hit left wall
{
addToMap(--currentX, currentY, 1); // hallway
newDirection = randomDirection(new [] { 1, 1, 1, 3 });
}
else
{
if(currentY < Y_BOUNDS)
{
addToMap(currentX, currentY, 2); // hole in floor
addToMap(currentX, ++currentY, 3); // drop room
newDirection = 2; // go right now
}
else
{
++currentY; // we hit a wall, go up
}
}
}
else if(newDirection == 2) // right
{
if(currentX < X_BOUNDS) // didn't hit right wall
{
addToMap(++currentX, currentY, 1); // hallway
newDirection = randomDirection(new [] { 2, 2, 2, 3 });
}
else
{
if(currentY < Y_BOUNDS)
{
addToMap(currentX, currentY, 2); // hole in floor
addToMap(currentX, ++currentY, 3); // drop room
newDirection = 1; // left
}
else
{
++currentY;
}
}
}
else if(newDirection == 3) // down
{
if(currentY < Y_BOUNDS)
{
addToMap(currentX, currentY, 2); // hole in floor
addToMap(currentX, ++currentY, ); // drop room
newDirection = 0; // random
if(currentX == X_BOUNDS)
{
newDirection = randomDirection(new [] { 1, 1, 3 });
}
else if(currentX == 0)
{
newDirection = randomDirection(new [] { 2, 2, 3 });
}
else
{
currentY++; // maxed; exit loop
}
}
}
}
void addToMap(int x, int y, int roomType) { ... }int randomDirection(int[] dirs) { ... }

If the multiple usages of 0, 1, 2, and 3 for both the path direction and the room type may be confusing, I’d suggest turning these into enumerations in whatever you’re working on. They’re here in their most simplist form for the sake of making the example as approachable and convertable as possible to any language.

Luckily, with the above being the heavy lifting, this means the math isn’t that wacky for the rest of the map after this step. We merely need to add the rest of those filler rooms around this path we’ve populated for the route.

generateMap(int width, int height)
{
createRoute(width, height);
for(int y = 0; y < height; y++)
{
for(int x = 0; x < width; x++)
{
if(!rooms.KeyExists(new Vector2(x, y))
{
addToMap(x, y, RoomType.filler);
}
}
}
}

I used a dictionary (C# data structure) for the rooms in my example to cut down on as much code as possible and make this more about the generic map layout vs. the implementation.

To facilitate this in an isometric layout in Unity, I was able to translate the existing method (or rather an approximation) and map the 2D layout we see from the side-view of a platformer to an isometric tile map. The grid mapping in Unity for isometric layouts (x, y, z — camera isometric) works exactly the same as one done the other way (x, y — camera flat). The grid merely lays on the side instead of on the bottom.

An example of the room layout I build using Unity with a similar method of map generation to the above. The rooms here are prefab objects of tile sets that are loaded into one big map tile set.

Since I wasn’t totally sure what the best method of storing the room data was, I ended up using prefabs in Unity. These have the downside of being a tad less performant since I need to effectively deserialize my prefab of tiles and serialize them again onto the map. I’m not a game programmer, but I believe storing the data in mini-array’s would save me some time and make them more portable.

“Any downsides?”

Of course! One is of terrain and the other of scale.

If I want more interesting terrain later and decide generate a height map and pop a preconfigured room on top of it, I could have issues reconciling which height (room or map) is the one I should go with. Makes it a tad cumbersome to have another system to figure that out. Ideally, I’d generate my map, generate objects (players, items, enemies), and let the player play as quickly as possible.

The issue of scale is that of my rooms needing to match the inherent scale of my entire map. For example, if my room size is 8 x 8 tiles and my map is 4 x 4 rooms, I’m looking at a generic map size of 16 rooms. Those rooms rely on each other room fitting within the confines of that 32 tile height and width (8 tiles * 4 rooms). Some teams have solved this by creating the concept of large rooms (multiple that fit together), but I’d like a solution that allows for a little more variety.

The Minecraft Method*

*or generating a map with terrain based on a noise algorithm that can fit any map size. An entrance and exit is not factored into the initial map generation.

To create maps in Minecraft, a Perlin noise algorithm is used to generate a height map bound to certain peaks and valleys and then this map is populated into a set of blocks on a plane. My favorite breakdown of this method, along with additions for other noise methods as well as philgms, lakes, and rivers for the map, comes from another Medium author in “Replicating Minecraft World Generation in Python”.

This is especially trivial in Unity where the same Perlin noise algorithm is available in a helper function.

MapOptions mapOptions; // Scriptable Objectvoid Start()
{
seed = Random.Range(1000000, 9999999);
tiles = GetComponent<Tilemap>();
generateTerrain();
}
void generateTerrain()
{
float terDetail = mapOptions.MapDetailAmt;
float terHeight = mapOptions.MapHeightMax;
Tile tile = mapOptions.tile;
for (int x = 0; x < mapOptions.MapLength; x++)
{
for (int y = 0; y < mapOptions.MapWidth; y++)
{
int z = (int)(Mathf.PerlinNoise((x / 2 + seed) / terDetail, (y / 2 + seed) / terDetail) * terHeight);
tiles.SetTile(new Vector3Int(x, y, z), tile);
}
}
}

There’s a couple concepts abstracted out from here, but the method should be similar if you’re populating a grid with some tiles that have random height. The map length and width are the number of tiles that constitute those elements. Terrain max allows me to not let the height get too out of control. And so on.

When you take that noise and pop it into a tile set like with the Spelunky map, you get something like the following.

The result of using Perlin noise to generate a height map that’s being loaded into a tile map in Unity.

By using those Scriptable Objects in Unity that correspond to different map types with height, width, max terrain, and some preset tiles I was also able to switch my random maps easily like so.

This map is generated using the same logic as the previous plains version, but with a different desert tile, an increased height, width, and a lower max height on the terrain.

“What about here? Any downsides?”

There was something nice about having an entrance and an exit on the map, wasn’t there? I’m aiming for some bit of exploration and the idea of having a critical path tickled me a little. I like allowing the player to either exit the map or fight enemies for victory as well. This method doesn’t consider that critical which makes the easier solution one which picks a random entrance tile on the x-axis and a random exit on the other end of the y-axis.

This also makes maps less easy to populate with enemies and items. Why? Well, now I don’t have the context of a generic “room” in every slot on the map that would allow me to slot in what I expect it to have. Not a huge deal, but certainly a positive of the Spelunky generation method understanding the room structure of the map.

The Method I Settled On*

*or stealing from two of the most popular games to make a “new thing” that makes you sound smarter than you are

My method ultimately uses a hybrid of both of the previous to give me the benefits of having a critical path from Spelunky and more interesting terrain that Minecraft allows.

Starting out, I use the Spelunky pathfinder to create a critical path that moves through my map bounds (in tiles). I tweaked this slightly to be configurable. Instead of “rooms” being traversed, I supply a generic “path size” variable that lets me crawl the map in 2 block, 4 block, or whatever size chunks I want. Since I don’t need the filler rooms anymore I only create the critical path and return it as quickly as I can.

After I have my path, the terrain map is generated, albeit with one big difference: I can now provide my path during this terrain phase. This is used at tile map population time to optionally insert “path terrain” into my base map at a slightly lower height.

MapOptions mapOptions; // Scriptable Objectvoid Start()
{
seed = Random.Range(1000000, 9999999);
tiles = GetComponent<Tilemap>();
List<Vector2Int> path = generatePath(2);
generateTerrain(path);
}
void generateTerrain(List<Vector2Int> path) // new!
{
float terDetail = mapOptions.MapDetailAmt;
float terHeight = mapOptions.MapHeightMax;
Tile tile = mapOptions.tile;
// new!
Tile pathTile = mapOptions.pathTile;
for (int x = 0; x < mapOptions.MapLength; x++)
{
for (int y = 0; y < mapOptions.MapWidth; y++)
{
// new!
if(path != null && path.Exists(p => p.x == x && p.y == y)
{
tiles.SetTile(new Vector3Int(x, y, 0), pathTile);
continue;
}
int z = (int)(Mathf.PerlinNoise((x / 2 + seed) / terDetail, (y / 2 + seed) / terDetail) * terHeight);
tiles.SetTile(new Vector3Int(x, y, z), tile);
}
}
}
generatePath(int pathDetail) { ... }

The result is a terrain map that includes a critical path driving directly through it.

Using noise generation for the base map that includes dynamic terrain and the original path finding for a flat, critical path that drives through the core of the map.

Understanding now what my critical path is, I now have a couple additional perks compared to my original Minecraft terrain map.

  • I can insert an entrance and exit into my map.
  • I know where the critical path is.
  • I know where the critical path isn’t.

That last point is the most important. Similar to the Spelunky method, I can now understand where the player should and shouldn’t be. While not as powerful as individual rooms for laying out items and enemies, I can easily add a small system in to set rarer items and more powerful enemies further from the path for any players that decide to leave the critical path.

“Certainly, there has to be a downside…”

There are a couple big negatives with the approach I settled on.

One is that I still don’t have a solid understanding of zones or rooms in the map I create. I think using the path as a reference point will provide a decent enough replacement, but I still won’t be able to grab a random room and say, “Put treasure here!” or “Put a tough enemy here!”

Another is noticable from the screenshot. I’m now reducing my path to 0 on the z-axis for the terrain on it and it provides some big gaps between terrain and the path. I think this will be an implementation detail, however, that I can correct by either layering tiles over various heights or by reducing the max height on certain maps to make it less wide of a gap.

What next?

I’m not sure yet. I’d like to fix those gaps in the terrain. I’m currently working on enemy generation and AI. Maybe once I have some basic version of those features, I’ll write a bit more on how the project is shaping up.

Anyway, thanks for reading. Check out the references below if you’re looking for more information on these concepts or some other great content from a few smart engineers that have written about similar concepts. Cheers!

References

--

--

Patrick Ramser

34 year old boy, masquerading as a software engineer. I write short stories and all the code you can imagine. Coffee fuels everything I do. ☕️💻