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 design type.
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] ]
If you want certain biomes appear more or less, check out Wireframe’s issue 1 and 2 essay on how probability is implemented, p.34 ff:
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]
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() + x, coordinates() + 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] var pos_z = pattern[tile] # 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:
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.