Sometimes there's a man... I won't say a hero, 'cause, what's a hero? But sometimes, there's a man. And I'm talkin' about the Dude here. Sometimes, there's a man, well, he's the man for his time and place. He fits right in there. And that's the Dude, in Los Angeles. - The Stranger, The Big Lebowski

That quote has nothing to do with this article, but it's a great movie. You should go watch it and then come back and read this article. Tilemaps! This is going to be about making tilemaps, but first let's do some role-playing.

We are working on creating a persistent space simulation game where players will pilot a ship around star systems, traveling to points of interest in each system such as a space station, a star, and a planet. Player ships can travel between star systems. We render each star system as a top-down, two dimensional tilemap. We need to write the code that creates our game's tilemaps. ...and we need to do it in Javascript.

The challenge

Now if we intended only a few star systems we might consider making these tilemaps and positions of their respective assets by hand. We might store these as 2d arrays, and it might look something like this:

const coruscantSystem = [
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 3, 0, 0, 0],
    [0, 0, 1, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 2, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
]

Each index of the array is an integer that represents a type of tile. 0 is for a walkable (or in our case "flyable") tile. 1 is a star, 2 is a planet, and 3 is a space station. This is very basic, but you get the idea. We could expand on this and have other kinds of walkable tiles with maybe a nebula as terrain that slows the player's ship or other things like that.

In our game we have over 2200 star systems. That's a lot of tilemaps to make by hand, so naturally we should think about how to automate this! That's what computers are great at, after all. We could write a method that randomly assigns one of our 0-3 integers and walks through creating each row. It might look like this:

const tileTypes = [0,1,2,3]
const randomType = () => tileTypes[Math.floor(Math.random() * tileTypes.length)]

const randomStarSystem = () => {
    const tilemap = []
    for (let row = 0; row < 10; row++) {
        tilemap.push([])
        for (let column = 0; column < 10; column++) {
            tilemap[row].push(randomType())
        }
    }
    return tilemap
}

randomStarSystem()

// produces something random like :
// [
//   [2, 0, 0, 1, 2, 0, 3, 0, 3, 3]
//   [2, 2, 2, 0, 2, 3, 3, 2, 2, 3]
//   [0, 2, 3, 2, 2, 1, 2, 0, 1, 2]
//   [1, 1, 1, 1, 0, 0, 1, 0, 1, 0]
//   [0, 1, 2, 1, 1, 0, 3, 2, 2, 0]
//   [1, 3, 2, 1, 0, 1, 0, 2, 0, 3]
//   [2, 1, 1, 0, 1, 0, 3, 3, 0, 0]
//   [1, 0, 2, 3, 0, 1, 0, 1, 2, 3]
//   [1, 0, 1, 0, 2, 1, 3, 3, 2, 2]
//   [3, 3, 1, 2, 2, 2, 3, 1, 2, 0]
// ]

If you were to run this code in your browser's dev tools you would see several issues right away (or you may have spotted sooner ;) ). For one, we aren't controlling the ratio of tile types at all! There may be 50 planets or 50 stations. Secondly, there may be weird edge cases where players are cutoff from flying to parts of the grid because things obstruct them (if we decided some of our tile types were unwalkable).

We could adjust this code to simply check if one of our limited types exists already and swap it with a walkable 0 if it does. That'll get us a little further:


const tileTypes = [0,1,2,3]
const randomType = () => tileTypes[Math.floor(Math.random() * tileTypes.length)]

const randomStarSystem = () => {
    const usedTileTypes = []
    const tilemap = []
    for (let row = 0; row < 10; row++) {
        tilemap.push([])
        for (let column = 0; column < 10; column++) {
            const tile = randomType()
            if (usedTileTypes.includes(tile)) {
                tilemap[row].push(0)
            } else {
                tilemap[row].push(tile)
                usedTileTypes.push(tile)
            }
        }
    }
    return tilemap
}

randomStarSystem()

// our revised randomStarSystem now produces something random like:
// [
//   [1, 3, 0, 0, 0, 0, 2, 0, 0, 0]
//   [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
//   [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
//   [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
//   [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
//   [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
//   [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
//   [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
//   [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
//   [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
// ]

That's a bit better. That at least ensures we only have 1 tile of star, planet, and station while all the rest are walkable. This is decent, and there's more we really should do, at a minimum, as this will likely always put the resources in the upper-left part of the grid. We could loop back over the array and further randomize the position of our 0,1,2,3's.

But before we go down that path consider another problem: while this randomized tilemap may work for a roguelike type of game where each level is totally new, what we are making is a persistent browser-based game. Specifically, it is a multiplayer space sim. We want every player to experience the systems of the galaxy together and be able to play in locations that are the same when they leave and return to those areas (at least as concerns the map and static-positioned things).

This means that completely randomized maps won't work for our game. We need the maps to be repeatable, but we also want variety! They can't all look the same. That would be boring, and where is the motivation for players to explore if the maps are the same small set of tile layouts used over and over again for a galaxy of 2200 systems?

So the question is how do we dynamically generate tilemaps that are each unique but can be generated in a repeatable way each time so that any given star system map is always the same? Here's where the fun begins.


Bring the noise!

One solution we can reach for is Gradient Noise, specifically the Perlin Noise algorithm. Per wikipedia, "noise is commonly used as a procedural texture primitive in computer graphics". Check out this cool example of using noise to create graphics.

One of the neat things about noise algorithms is that given the same set of inputs they will produce the same results. So, for our game we know that each star system has at least one unique thing about it - it's coordinate location in the larger galaxy! We have an X and a Y coordinate we can use that is always a constant for each system and is also a unique key by which we can pass to the Perlin noise algorithm. Before we do that, let's go over the basics of using Perlin noise in javascript.

I am not going to go into the inner workings of the algorithm itself, as that could be a topic of its own. Rather, I will select a library that has wrapped up a particular implementation of it and use it to attain the Perlin noise values. The library is called noisejs and it has a straightforward api. We provide a global seed number which will be used in all permutations of the algorithm, and then we pass an X and a Y value to the individual noise algorithm methods. To demonstrate, from the library's readme:


const CONSTANT_SEED = 1234
noise.seed(CONSTANT_SEED)
const x = 1
const y = 2
const value = noise.perlin2(x, y)
// value is a number in the range of -1 to 1!

This is great for us because we can pick a constant for our seed and then pass each star system's X/Y coordinates into the noise method to get a dynamic, random-like-but-repeatable value for every system. Technically, there may be a chance of a repeat here or there, but this is much, much better than where we were a moment ago! However, we're not done. We still need to go further.

The solution

We have a single unique value for the entire tilemap, but we really need unique/repeatable values for each tile in that tilemap. Can you think of two more unique-but-repeatable values for each tile?

If you answered the x/y position of each tile in the tilemap then congratulations. You win a slice of pie, and FoohonPie will hook you up in the PBBG.com Discord server. Tell him I said it was ok.

So we might do this to further extend how we use our unique noise inside of our loop:


const x = 1
const y = 2
const systemNoiseValue = noise.perlin2(x, y) // this is just for the system as a whole

const tileNoises = [] // these are the unique noise values for each tile in each system!
for (let r = 0; r < 10; r++) {
    for (let c = 0; c < 10; c++) {
        const systemTileNoiseValue = noise.perlin2(x * r / 100, y * c / 100)
        tileNoises.push(Math.abs(systemTileNoiseValue)) // using Math.abs() here because it'll help with things you'll see later on
    }
}

I used multiplication here (x * r and y * c) but it could have been any consistent math operation to combine the systemNoiseValue with the x and y of the tile position. So this works, but now what do we do with these 100 tilemap noises we just made? Well, our next order of business is to figure out which of these will be our 1 tile, our 2 tile, and our 3 tile. Everything besides those will use the default 0 tile. Basically, we want to sort this array of noise values and then take specific indexes (like the first and last) or range of indexes.

Let's sort our noises by value, and then we'll take three specific indexes - the lowest, highest, and right in the middle:


//....omitted above

const sortedNoises = [...tileNoises]
sortedNoises.sort()
const lowest = sortedNoises[0]
const highest = sortedNoises[sortedNoises.length - 1]
const middle = sortedNoises[sortedNoises.length / 2]

As an aside, this works for the specific requirements of our game's tilemap. However, what if you're making a fantasy overworld map, and you need multiple kinds of walkable terrain? Also, what if perhaps some terrain needs to be adjacent to others? Because the perlin and simplex noise algorithms generate graphics there are general ranges of values that shift gradually you should be able to assume a -1 value won't be precisely adjacent to a 1 value. For this reason you can divide the array of values by a range, and you can assign that range to a tile type.

Let's say we want a terrain adjacency pattern that is like "grass > beach > water". We could do this:


const waterTiles = sortedNoises.filter(noise => noise < .2)
const beachTiles = sortedNoises.filter(noise => noise > .2 && noise < .3)
const grassTiles = sortedNoises.filter(noise => noise > .3 && noise < .6)
const allOtherTiles = sortedNoises.filter(noise => noise > .6)

// and because we used Math.abs() earlier now we are only dealing with
// a positive number array and can use < and > operators to encompass all easily

However, for our game we have chosen the specific index approach because it's all we really need. Let's look at the final code!


// not including import code here 

const CONSTANT_SEED = 1234
noise.seed(CONSTANT_SEED)

function generateTileMatrix(x, y) {
    const tileNoises = []
    for (let r = 0; r < 10; r++) {
        for (let c = 0; c < 10; c++) {
            tileNoises.push(Math.abs(noise.perlin2(x * r / 100, y * c / 100)))
        }
    }
    const sortedNoises = [...tileNoises]
    sortedNoises.sort()
    const lowest = sortedNoises[0]
    const highest = sortedNoises[sortedNoises.length - 1]
    const middle = sortedNoises[sortedNoises.length / 2]
    const finalMatrix = []
    for (let r = 0; r < 10; r++) {
        finalMatrix.push([])
        for (let c = 0; c < 10; c++) {
            const noisesIndex = r * 10 + c
            let tileValue = 0
            if (tileNoises[noisesIndex] === lowest) {
                tileValue = 3
            }
            if (tileNoises[noisesIndex] === highest) {
                tileValue = 2
            }
            if (tileNoises[noisesIndex] === middle) {
                tileValue = 1
            }
            finalMatrix[r].push(tileValue)
        }
    }
    return finalMatrix
}

The x and y of the generateTileMatrix() method are the unique coordinates of each star system. If we had decided to use a range instead of specific indexes our finalMatrix loop may have looked more like this:


//...after sorting noises

const waterTiles = sortedNoises.filter(noise => noise < .2)
const beachTiles = sortedNoises.filter(noise => noise > .2 && noise < .3)
const grassTiles = sortedNoises.filter(noise => noise > .3 && noise < .6)
const allOtherTiles = sortedNoises.filter(noise => noise > .6)
const finalMatrix = []

for (let r = 0; r < 10; r++) {
    finalMatrix.push([])
    for (let c = 0; c < 10; c++) {
        const noisesIndex = r * 10 + c
        if (waterTiles.includes[tileNoises[noisesIndex]]) {
            tileValue = 3
        }
        if (beachTiles.includes[tileNoises[noisesIndex]]) {
            tileValue = 2
        }
        if (grassTiles.includes[tileNoises[noisesIndex]]) {
            tileValue = 1
        }
        if (allOtherTiles.includes[tileNoises[noisesIndex]]) {
            tileValue = 0
        }
        finalMatrix[r].push(tileValue)
    }
}

//...omitted below

There we have it!

Our space sim PBBG game can support massive numbers of 2d tilemaps that are generated from noise functions to create a dynamic maps experience with variability but also predictability. This experiment is really meant as more of a demonstration of some approaches to creating dynamic tilemaps. There would be some refinement you'd want to do for sure, but it's a start! Good luck, go forth and make some noise.