Creating an endlessly repeating map in Godot

The first time leaving the Great Plateau in Zelda: Breath of the Wild was breathtaking. The pure amount of vast landscape waiting to be explored was what the fanbase was requesting for years.
So what's the problem with an open-world Zelda? Well it's its open world.

Breath of the wild wraps its map into a square. While most boundaries are naturally hidden by canyons, mountains or water. The south-western desert even suffers from the traditional flaw that breaks the immersive experience down to pieces, literally telling you:

“You can’t go here”

On my last itch.io game jam participation the concept I came up with an idea implementing a level design system which should get rid of this design flaw and implement the true idea of an open-world. Of course being game designers, we need some trickery. Thus by design we are going to make the player assume our world has no boundaries.

Problem-solving open-world flaws

Actually the idea including an repeating map this attempt is close to our earth’s one. Commonly projected in rectangular space, we know that projections of spheres are error biased due to their natural calculation. Nevertheless, since we don’t need to create a whole sphere we can concentrate on the planar form of this form of design topic.

Approaching infinity

In this example I am going to use a 10x10 map which will be stored in a two-dimensional matrix: each tile will be appended into an array, representing one horizontal line of the map which will be part of another array. We also want to implement different types of tiles or biomes. So far this is common tile-map creation 1.

The method we will implement is used in a 3D environment. Nethertheless it can be easily used in 2D replacing all 3D vectors by Vector2(x,y). For performance reasons we will only spawn and show 9 tiles at a time: the center — which is the player’s current position — and 8 additional tiles radially positioned surrounding the center. Based on your setup, the use of LOD for example — you might consider custom tweaks to its configuration.

Setting up global variables

Create a node on your scene’s root node which you might want to call terrain or map. Next we attach a new script to it. Let’s take a breath and think what we need at first.

We define the count of our rows/coloumns by the level_size. The tile_size will be determined by our actual map tile nodes. Thus, you should make sure all tiles share the same size. scope is the count of rows/columns we spawn next to the center position.

We also need two arrays for saving our map and player’s coordinates. This is imporant to differentiate that there was an actual change to spawn new tiles later on.

extends Node

var level_size = 10
var tile_size = 100
var scope = 3
var radius = int(floor(scope/2))
var biomes = ["a", "b", "c", "d", "e", "f", "g", "h"]

var map = []
var saved_coords = []

Creating the map skeleton

At initialisation of the game loop we want to store the map into our variable. Unless other engines, Godot is slightly different about randomization. At runtime generated randoms are held in the cache unless you reset the cache. To make sure Godot does randomize a new seed every session we thus call randomize() to initiate a new seed every time the map creating function eventually.

Next we iterate through our 2D coordinates and populate every tile with a random biome based on our biomes array:

func create_map():
	# Refresh seed
	randomize()

	# Create a map that holds the level tiles
	for x in range(level_size):
		var row = []
		for z in range(level_size):
			row.append(biomes[int(rand_range( 0, biomes.size() ))])
		map.append(row)

The output will look like this:

[ [d, h, a, a, c, b, c, g, a, a],
  [b, b, c, g, c, d, g, d, e, d],
  [g, d, h, g, b, e, g, e, d, a],
  [d, d, f, g, d, e, e, h, d, h],
  [c, a, c, g, f, d, f, a, a, h],
  [h, f, a, d, f, e, h, c, f, d],
  [e, g, c, e, a, c, d, d, a, h],
  [g, h, b, h, c, f, g, a, e, g],
  [b, d, g, f, b, d, d, f, d, e],
  [b, a, d, c, c, c, d, d, h, f] ]

Probability

If you want certain biomes appear more or less, check out Wireframe’s issue 1 and 2 assay on how probability is implemented, p.34 ff:
www.wfmag.cc/issues↗︎

Detect the player and setup the tiles around him

The next function will detect the player’s coordinates in the game engine and convert them accordingly to our map grid.

func coordinates():
	var x = floor(get_parent().get_node("Player").get_translation().x)
	var z = floor(get_parent().get_node("Player").get_translation().z)
	x = int(x / tile_size) % (level_size)
	z = int(z / tile_size) % (level_size)
	return [x, z]

Modulo

In computing, the modulo operation finds the remainder after division of one number by another.1


  1. Definition of modulo given by wikipedia [return]

Converting the spatial player’s position first we divide it by the tile size and then get the remainder with modulo. Next, we create a function that returns the current pattern, based on our converted position,so we iterate through the corresponding arrays:

func pattern():
	var pattern = []

	# Lists current visible tiles
	var x = -radius
	var z = -radius

	# Create a two-dimensional grid. Size is based on our predefined scope
	for a in scope:
		for b in scope:

			var id = [coordinates()[0] + x, coordinates()[1] + z]
			pattern.append(id)

			if x < (radius * radius):
				x += 1
		if z < (radius * radius):
			z += 1
		x = -radius
		
	return pattern

A perfect squared circle

The allocation of the calculated pattern according to our map array is crucial, yet similar to the pattern() function. First, we are going to check if the tile is already spawned.

To make sure the map repeats correctly we have to make sure the array calls the first or last instead index instead of exceeding the array count. Thus we have to subtract respectively add it to or from the level size — which is simply map.count().

We instantiate the tile by inserting the string we received from the array by calling load(“res://tiles/%s.tscn” % str(type)). When it’s ready for instantiation it will be named uniquely by its spatial coordinates, thus helping us to differentiate it from the others.

func spawn_tiles():
	var x = int(floor(get_parent().get_node("Player").get_translation().x))
	var z = int(floor(get_parent().get_node("Player").get_translation().z))

	var pattern = pattern()
	var tile = 0
	var offeset_x = -radius
	var offeset_z = -radius

	for a in scope:
		for b in scope:
			for new in pattern:
				if pattern[tile] == new:
					var pos_x = pattern[tile][0]
					var pos_z = pattern[tile][1]

					# If the value is negative or higher than the level size we have to normalize it
					if pos_x < 0:
						pos_x = level_size + pos_x
					elif pos_x >= level_size:
						pos_x = level_size - pos_x

					if pos_z < 0:
						pos_z = level_size + pos_z
					elif pos_z >= level_size:
						pos_z = level_size - pos_z

					# Make sure the received values are always positive
					if pos_x < 0:
						pos_x *= -1
					if pos_z < 0:
						pos_z *= -1

					var type = map[pos_x][pos_z]
					
					var tilescene = load("res://tiles/%s.tscn" % str(type))
					var new_tile = tilescene.instance()

					# The following 6 lines are optional and are meant to beautify the spawn position if the value is negative
					if x % tile_size != 0:
						var mod = x % tile_size
						x = x - mod
					if z % tile_size != 0:
						var mod = z % tile_size
						z = z - mod

					var tile_x = int( x  + ( offeset_x * tile_size ))
					var tile_z = int( z  + ( offeset_z * tile_size ))

					var name = str(tile_x) + str(tile_z)

					new_tile.set_translation(Vector3(tile_x, 0, tile_z))
					new_tile.set_name("tile" + str(name))

					self.get_parent().call_deferred("add_child", new_tile)

			tile += 1
			if offeset_x < (radius * radius):
				offeset_x += 1
		if offeset_z < (radius * radius):
			offeset_z += 1
		# Reset the iterator when "a" iterates
		offeset_x = -radius

    # Clear the old coordinates and save the current ones
	saved_coords.clear()
	saved_coords = coordinates().duplicate()

Looping the loop

Now that we got our main functions we can initiate them in _ready().

func _ready():
	# The scope value has to be at least one-third of the level-size.
	# This will prevent a false scope value from an invalid array call on our map
	if level_size / scope < scope :
		level_size = scope * 3

	create_map()
	saved_coords = coordinates().duplicate()
	spawn_tiles()

On this first run, we need to make sure to save the current coordinates, so we can determine when a change happens to instantiate tiles determined by the new pattern and free those from the old one:

func _physics_process(delta):
	# Create new tiles if
	# position has changed
	if coordinates() != saved_coords:
		for tile in get_parent().get_children():
			if "tile" in tile.name:
				tile.queue_free()
		spawn_tiles()

The result of the code:

A second camera from above shows us the code works as expected:

Afterword

Use this example as a boilerplate. There is still room for optimization, like implementing transitions between biomes or spawning only those tiles that are faced by the viewport and much more.