Instantiating a Procedurally Generated Platformer in Unity

March 27, 2017
protect

In my last post, we discussed a simple way you could randomly generate a two-dimensional array of values representing ground, platform, and trap tiles. For example, your result might look like this:

The trick, of course, is to convert each of these chunks into actual Unity objects, and to do so in a way that performs well.

Because we’ll be adding new chunks as needed, we want to make sure we don’t freeze play due to adding large chunks that are costly to instantiate. Because we want to restart the level quickly (without reloading the scene), we want to minimize the cost of creating new game objects.

Pool ALL the objects

I’ll admit to a certain amount of superstitious cargo cult programming. XNA has made me deathly afraid of allocating new objects (which is why you’ll often see me creating temporary method variables as private fields) and SharePoint has made me scrupulous about the use of using/Dispose.

Unity’s contribution to my neurosis is object pooling. I build an object pool system anytime I instantiate new objects, whether or not I really need it for a simple 2D game jam game.

The general idea of object pooling is that the creation and destruction of new objects are expensive, so you should instead deactivate (rather than destroy) and reuse (rather than create).

In my endless runner, I have a very simple construct where each tile type is represented by an int, and then I have a Dictionary that essentially caches a set of game objects for each tile type in my ChunkManager class:

public enum LevelBlock : int
{
    Nothing = 0,
    Floor = 1,
    Platform = 2,
    Spike = 3,
    Laser = 4,
    FlameLR = 5,
    FlameUD = 6,
    CannonLR = 7,
    CannonUD = 8,
}

Dictionary<int, List<GameObject>> blockPool = new Dictionary<int, List<GameObject>>();

I can then go through my generated level chunk tile-by-tile, reusing old, deactivated blocks wherever possible and instantiating new blocks as necessary:

public GameObject[] Prefabs;

GameObject InstantiateBlock(Transform parent, int index, float x, float y)
{
    // Get from pool
    for (int i = 0; i < blockPool[index].Count; i++)
    {
        if (!blockPool[index][i].activeSelf)
        {
            blockPool[index][i].SetActive(true);
            tmpPos.x = x;
            tmpPos.y = y;
            tmpPos.z = 0;
            blockPool[index][i].transform.parent = parent;
            blockPool[index][i].transform.localPosition = tmpPos;
            return blockPool[index][i];
        }
    }

    // Instantiate new
    tmpBlock = (GameObject)Instantiate(Prefabs[index]);
    tmpPos.x = x;
    tmpPos.y = y;
    tmpPos.z = 0;
    tmpBlock.transform.parent = parent;
    tmpBlock.transform.localPosition = tmpPos;
    blockPool[index].Add(tmpBlock);
    return tmpBlock;
}

To put it simply: each tile value is represented by a number. That number also maps to an index in a set of prefabs, as well as a set of object pools. When tile 1 is requested, we create prefab 1 and store it in block pool 1.

Because my code is encapsulated cleanly enough, I can pre-populate this cache of blocks on startup, rather than waiting until the moment the player starts playing the game:

void WarmupPools()
{
    for (int i = 0; i < blockPool.Count; i++)
    {
        for (int j = 0; j < 50; j++)
        {
            InstantiateBlock(null, i, 0, 0).SetActive(false);
        }
    }
}

Creating the first chunk

Now that we’ve got an object pooling system for our tiles, we need to write some code that actually converts our two-dimensional array of tile values into Unity GameObjects. In this case, I used a method called InstantiateChunk, which we’ll see below.

This method takes a LevelChunk object, which contains the two-dimensional tile value array, as well as some information about where the chunk is located in Unity’s world space, and how it connects to other chunks. We’ll discuss this later when we handle that piece of the puzzle.

public float TileSize = 0.5f;
List<Transform> chunkTransforms = new List<Transform>();
void InstantiateChunk(LevelChunk chunk)
{
    // Create a parent container
    GameObject parent = new GameObject();
    parent.name = "Level Chunk";
    chunkTransforms.Add(parent.transform);

    // Set the chunk's position information based on the last chunk and the tile size
    chunk.WorldLength = chunk.Blocks.GetLength(1) * TileSize;
    chunk.WorldHeight = chunk.Blocks.GetLength(0) * TileSize;

    tmpPos.x = chunk.WorldX = (chunks.Count > 0) ?
               (chunks[chunks.Count - 1].WorldX + chunks[chunks.Count - 1].WorldLength) :
               transform.position.x;
    tmpPos.y = chunk.WorldY = (chunks.Count > 0) ?
               chunks[chunks.Count - 1].WorldY :
               transform.position.y;
    tmpPos.z = 0;
    parent.transform.position = tmpPos;

    // Generate blocks
    for (int y = 0; y < chunk.Blocks.GetLength(0); y++)
    {
        for (int x = 0; x < chunk.Blocks.GetLength(1); x++)
        {
            if (chunk.Blocks[y, x] == LevelBlock.Nothing)
                continue;

            tmpBlock = InstantiateBlock(parent.transform,
                       (int)chunk.Blocks[y, x] - 1,
                       x * TileSize,
                       y * TileSize);

            if (chunk.Blocks[y,x] == LevelBlock.Floor)
            {
                SetFloorSprite(tmpBlock, chunk, x, y);    
            }
        }
    }

    parent.layer = LayerMasks.WorldLayer;
    PolygonCreator.CreatePolygons(parent, chunk.Blocks);
}

 The first thing to notice is that each chunk is instantiated as an empty parent object containing tiles. You don’t have to do this in your implementation, but I found it made positioning and debugging a lot easier.

You’ll also note that we’ve defined each tile’s size (in our case, they’re square, so length and width are the same) and we use it for positioning.

The last method calls out to a class called PolygonCreator, which creates a polygon 2D collider for each section of tiles in a chunk.

PolygonCreator exists because it’s not practical to add a box collider to each tile prefab.

I actually tried this to start out with, but the player often got stuck in between connected tiles. I suspect this was because of how collision detection behaves when there were two possible colliders that could stop the player side-by-side. (In theory, it should also save on processing power, since each collider doesn’t have to be considered separately.)

Code for PolygonCreator is here; you’ll want to replace references to LevelBlock and various physics layers with your game’s implementation. The code itself is based on this description of Theo Pavlidis’ contour-tracing algorithm. (I never thought I’d be writing code for a mathematical process that could be described in terms of a ladybug.)

Creating subsequent chunks

Generating a few chunks at the outset is a good start, but as players progress through the level, we want to do two things:

  • seamlessly add new chunks before they’d appear on-screen

JikGuard.com, a high-tech security service provider focusing on game protection and anti-cheat, is committed to helping game companies solve the problem of cheats and hacks, and providing deeply integrated encryption protection solutions for games.

Read More>>