Baking game levels in Egg Savior

This article covers the way Egg Savior handles the rendering of static objects in order to save both CPU and APK size. The method presented is implemented into the last version of Egg Savior and is working really well, given the limited size of game levels.

The problem

Egg Savior currently has 30 levels. Each one of these levels include a generic background and a lot of bricks that act as platforms for the main character (the chicken). The bricks are all 64×64 pixels in size, and placed in a grid. The obvious yet inefficient way of rendering and interacting with bricks is to handle all of them one by one:

for brick in brick_list:
    brick.render()
    character.detect_collision(brick)

This code is inefficient for two reasons:

  1. Renders the same bricks in the same way all the time, one by one.
  2. Forces to detect collisions with every single brick, even if they are stuck together creating a larget platform

The bricks are static, they don’t change during the gameplay. For this reason, it would be faster to render them just once into an intermediate buffer, and then just render this buffer on each frame. This saves a lot of CPU time at the expense of more memory usage. If you have huge levels it may not be a good solution, because devices limit the amount of memory granted to each application to a quite small value. For Egg Savior this worked very well, because the size of levels is somehow limited.

Different backgrounds

The first version of Egg Savior had only 10 levels, and the approach was to have a different background image for each level, with the bricks already included into it. It worked really well, but as more levels were added, it proved not very scalable, because each new level added a lot of data to the APK. This approach meant brick objects to be just invisible rectangles, without render code, because they were already drawn into the background. This enabled a way to optimize also point 2, because there were no reason for defining each brick individually. If three bricks were stuck together in a row, they were defined as a single larger brick, thus enabling less time to process collisions.

Run-time baking

The second iteration of the design, focused on solving the scalability problem with an algorithm that allowed baking the bricks above the background when loading a level. This also involved a way to easily define where to place the bricks. The solution was to use strings with spaces on empty cells and hashes on brick cells:

String levelPattern[] = {
        "           ",
        "           ",
        "#   #      ",
        "##  # #    ",
        "   ## # #  ",
        "## ## # # #",
};

With this language to define brick positions, we already know where to place the bricks on the background. Anyway, there is still a problem to solve: brick shape change depending on its neighbours. If a brick is the first or the last in a row, it has round corners, in other case, it has sharp corners in order to blend with the next or previous one. This leaves us with four cases to handle, depending on the previous, current, and next brick (the current brick is in the middle):

  1. [_#_]: The brick doesn’t have neighbours.
  2. [##_]: The brick has only a neighbour on the left.
  3. [_##]: The brick has only a neighbour on the right.
  4. [###]: The brick has neighbours on both sides.
Four brick shapes with enhanced contrast.

The algorithm to generate the background with the tiles is as follows (I removed some obvious code to compute drawBitmap rectangles to enhance algorithm readability):

public static Bitmap bake(String[] rows, Bitmap baseBackground) {
    int nRows = rows.length;
    int nCols = rows[0].length();
    Bitmap newBitmap = Bitmap.createBitmap(nCols * TILE_SIDE, nRows * TILE_SIDE, Bitmap.Config.ARGB_8888);
    Canvas canvas = new Canvas(newBitmap);
    canvas.drawBitmap(baseBackground, null, ..., null);
    
    for (int row = 0; row < nRows; row++) {
        for (int col = 0; col < nCols; col++) {
            char left = '#';
            char right = '#';
            if (col > 0) {
                left = rows[row].charAt(col-1);
            }
            if (col < nCols - 1) {
                right = rows[row].charAt(col+1);
            }
            char c = rows[row].charAt(col);
            Bitmap toRender = getTile(left, c, right);
            if (toRender != null)
            {
                canvas.drawBitmap(toRender, null, ..., null);
            }
        }
    }  
    return newBitmap;
}

The getTile method just handles the four cases mentioned earlier, and returns the right bitmap from the app resources.

Automatic collision optimization

The baking of the bricks into the background solved the render part of the original problem (point 1). The bricks, which now consisted only in invisible rectangles, were defined by hand, looking at the hashes and chosing a smart way of arranging them in a minimum set of rectangles. This is a highly boring work that discouraged me to add more levels, and the perfect candidate for automatization.

Finding the minimum set of rectangles that covers all the bricks is not an easy task, because there usually exists more than one way of covering all the bricks with rectangles. Sometimes the best way involves using overlapping rectangles. Even though finding the optimal solution was an interesting challenge, I realized that including complex (and maybe slow) code to reach perfection was not worth in this case. Having 10% more rectangles than the optimal solution didn’t impacted the performance in a visible way. I decided to go with a simple algorithm that provided a good enough solution.

Original bricks

The basic idea of the algorithm is to process each row independently, looking for contiguous blocks which define single rectangles, and then try to mix rectangles from consecutive rows if possible. As I said, this doesn’t find the optimal solution, it is just a greedy algorighm that provides a good enough one.

Column joins Row Joins

This is the pseudo-code:

previousRow = new Set[Rectangle]
currentRow = new Set[Rectangle]
rectangles = new List[Rectangle]
for row in rows:
    currentRectangle = None
    currentRow.clear()
    for char in row:
        if char == '#':
           if currentRectangle == None:
               currentRectangle = new Rectangle(colNum, rowNum)
             else:
                 currentRectangle.grow()
        if (char == ' ' or isRowEnd) 
           and currentRectangle != None:
           if canMix(previousRow, currentRectangle):
                  mix(previousRow, currentRectangle)
            else:
                 rectangles.add(currentRectangle)
       currentRow.put(currentRectangle)
    previousRow = currentRow
return rectangles

The algorithm already tries to mix rectangles with the previous row ones while they are generated, and does a pretty good job joining lots of bricks into big rectangles. Another optimization that I did to the algorithm is to encode the previousRow as an array with the same width as one row, where each position of the array has a pointer to the rectangle that covers this cell or to null if it is empty. This way, finding the right rectangle to mix with in the previous row runs in constant time. Actually, my implementation doesn’t need a currentRow structure, I overwrite the previousRow one after reading it…

I will not go into the actual implementation to avoid making this article infinite, but if anyone has some doubt on how to implement it, feel free to leave a comment and ask.

Conclusion

With the background baker and the rectangle optimizator, generating new Egg Savior levels reduces to actually thinking them and mostly writing a string with hashes. This code runs each time that the player starts a level, and is so fast that it is actually overshadowed by resource loading time.

Anuncios

Acerca de Rubén L.

Software Engineer
Esta entrada fue publicada en android, English y etiquetada , , , . Guarda el enlace permanente.

Responder

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Cerrar sesión / Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Cerrar sesión / Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Cerrar sesión / Cambiar )

Google+ photo

Estás comentando usando tu cuenta de Google+. Cerrar sesión / Cambiar )

Conectando a %s