This is the first of what will be a number of posts that go into how we generate the levels for our game.
A typical level in the game is somewhere in the region of 400 x 400 meters at the moment. Each level takes about two seconds to generate (all on the CPU), and the generator places somewhere in the region of 50,000 meshes to fully populate the world.
There are quite a few separate steps that the level generator runs through to generate a new world. I’ll detail most of these in later posts, but for now, here’s an overview of what the level generator does:
- Generate layout (high-level description of gameplay areas)
- Generate island (image maps representing world properties)
- Generate terrain (collision and meshes)
- Spawn items (meshes, traps, powerups, health fountains)
- Generate water render properties
- Generate a navmesh
- Render light environment
The layout is essentially a graph that dictates which parts of the level are guaranteed to be open (not cluttered by collidable meshes), and how each part connects to the others.
Later stages of the level generator will use the area of each node to decide how much “gameplay area” to burrow out of the map. Note that the circular shape of the nodes is just conceptual and that the final areas are more irregularly shaped.
There are quite a few different ways one could approach a problem like this (and I’m sure we tried quite a few of them!), but here’s how we ended up doing it…
The nodes are placed in layers in a hierarchical fashion. First, a single node is placed at the origin using the maximum radius allowed. For each subsequent node at the top level, an existing seed node is selected at random, then the layout attempts to place the new node within a certain distance (random within min/max) away from the seed node.
When placing a new node, it’s possible that the node would overlap with an existing one, and if that’s the case, then the new node is discarded and the layout tries again. The layout also ensures that the overall area of the bounding rectangle doesn’t get too large and will discard nodes that cause this constraint to be violated.
Once the top layer placement is complete, the layout proceeds to the next layer. In the next layer, the radius of the nodes is reduced (such that the area of the new node is half that of the previous), the offset distance is also halved, but the number of nodes to place is doubled. The layout proceeds using the same rules as previously to place the nodes and keep the world area reasonable.
The layout stage continues to place nodes like this up to a configurable number of levels. In the case of the image above, there are three levels.
After trying a number of node placement strategies, we found that this generates a nice distribution of nodes, can be quite varied depending on how the random numbers come out, but is still somewhat controllable via the level generator parameters.
As a final step, we relocate all nodes such that the center of the bounding rect is at the world origin. This makes dealing with the later stages of the generator a bit easier.
Connecting the Nodes
In general, it’s nice to have a decent balance between areas that are highly connected, and those that are not as well connected. If everything is connected then it feels like there are too many paths and loops to explore. If there are minimal connections, then it feels like you don’t have too many choices, and you constantly have to backtrack.
What ended up working best for us was to connect the nodes using a Relative Neighborhood Graph. The RNG is a subgraph of the Delaunay Triangulation of the nodes. As you can see from the example layout above, it generates some loops, while also keeping some dead-ends.
In our case, since the number of nodes is relatively low, it was easiest to generate the RNG using the dumb O(N^3) algorithm.
Place an edge between two nodes, a and b, only if there are no other nodes that are closer to both a and b than they are to each other. Quite a mouthful, but basically for all other nodes c:
distance(a, b) <= max(distance(a, c), distance(b, c))
The last thing the layout does is to choose on which node the level exit will be placed, and also where to spawn the player. For the exit, it chooses one of the top-level nodes because we need to ensure that there’s plenty of space around there for the boss fight. Once the exit node is selected, the furthest node away is selected as the player spawn node.
That pretty much covers the first step of the level generator. Follow our twitter feed if you’re interested in finding out more about the game. In the next post in the series, we’ll discuss how it goes about translating the layout into something more tangible that can be used to generate the terrain.