Smash/Riot Logo

Smash/Riot

Focusing on Interactive Media and Unique Experiences

Making of Trisector: Engine

The engine used to make Trisector

Jesse/SmashRiot

10-Minute Read

Trisector uses a custom side scrolling parallax shoot-em-up engine. This article will discuss the making of Trisector’s engine and some of it’s inner workings (aka how I reinvented the side-scrolling shoot-em-up wheel).

The engine for Trisector is written in Objective C using Xcode 4 targeting iOS 5.1 or greater. The engine uses the cocos2d v2.0 framework for handling sprites and sound. The Trisector project is fairly small at about 18k lines of code, which does not include cocos2d frameworks or data files such as xml, maps, fonts, graphics, etc.

Before we get into some of the guts of the Trisector engine, here are a few stats for Trisector v1.0.0 (160):

  • 39 .h files
  • 39 .m files
  • 673 lines of config defines
  • 2.4k lines .h code
  • 15.4k lines .m code
  • 4.9k lines of comments
  • 228 data files totaling about 11.9MB, which includes:
    • 28 sound/music samples (1.0MB)
    • 13 map files (257KB)
    • 30 backgrounds (3.1MB)
    • 11 font files (215KB)
    • 15 graphic and spritesheet files (1.9MB)
    • 7 icons (133KB)
    • 4 intro screens (782KB)
    • 7 splash screens (181KB)
    • 36 level previews (3.6MB)
    • 77 UI graphics and misc (771KB)

Trisector uses a GameLayer class that controls all of the input, output, and scheduling for the game. The GameLayer handles the changes in the device’s accelerometer and updates the player ship and game viewport accordingly. For each new game or attempt at a level, the GameLayer calls several other classes and methods, which perform the following:

  • init game board: loads the map and sets up the game tiles and objects for play
  • init player: creates the player and attaches the player to the game board
  • init HUD: creates the HUD including speed, energy, progress, and the mini-map
  • init music: sets up the music for the current level

The GameLayer has a simple active game loop and essentially performs the following:

  • update game board: calls an update method in the game board to update current game state
  • move player: moves the player ship based on current speed and accelerometer input
  • move viewport: moves the current viewport into the game board based on the player ship’s position
  • tick music: calls the MusicBox update method to play music
  • update HUD: updates the HUD including speed, energy, progress, and the mini-map

The tick interval speed is the amount of time it takes a tile column to move over one position in the game board. A major tick is when the column is fully moved over to the next column position, and a minor tick is when the column simply moves within it’s current column position. The tick interval speed starts out slow (0.30 seconds per major tick), and gets a bit faster (by 0.003) during each major tick until it reaches a lower bound (e.g. 0.10 seconds per major tick), which is moving pretty fast.

Culling is performed during the major ticks when a tile column has fully moved over one column. The culling includes removing a set of sprites for the oldest column on the left side, and adding a set of sprites for a new column to the right side.

The game board update loop essentially performs the following:

  • if major tick interval
    • update tick interval speed
    • move background: the background image moves 1pt per major tick
    • update wind speed: the wind particle layer is above the Base layer, and the speed of the particles is based on the interval speed
    • culling: culling will be described below and is responsible for adding and removing the following objects from the game board:
      • collision tiles
      • base tiles
      • objects
      • enemies
  • sub shift tiles: the Base layer and Collision layer are moved left each frame in relation to the current tick interval speed
  • animate objects: animates the power-up objects
  • perform enemy AI: responsible for controlling the enemies

The collision detection loop is on a separate schedule from the main update loop. The Making of Trisector: Collision Detection article describes the following process in greater detail:

  • Check player ship for collisions against power-ups, collision terrain tiles, enemies and enemy projectiles
  • Check player projectiles for collisions against collision terrain tiles, enemies and enemy projectiles
  • Check enemies and enemy projectiles for collisions against collision terrain tiles

During development of Trisector, I learned that all object creation needs to be performed before each level starts, and the objects need to be released after the level is done. Creating and/or releasing objects or tiles during the game is expensive and adversely affects the framerate, which needs to be a steady 60FPS to have the best experience.

Levels are loaded from the TMX map file for each attempt. The TMX Loader returns an array of NSNumbers for each tile layer, and an array of fully formed objects for the Object and Enemy layers. If the tile is transparent, then the NSNumber is set to -1 for that tile. Based on the array of NSNumbers for the tiles in the Base and the Collision layer, CSSprites are created and added to a CCSpriteBatchNode to the corresponding layer. Further, the TMX Loader creates the objects for the Object and Enemy layers and adds them to their corresponding NSMutableArray.

The following describes creating the Base layer’s column array that holds all of the CCSprite objects for that layer. All of the CCSprite objects for each layer are created at load time since creating and/or releasing objects while the level is being played is expensive.

// this baseTileArray is the w*h array full of NSNumbers from the TMX loader
// the NSNumber is -1 if there is no tile for that square
NSMutableArray *baseTileArray = [tileMapLoader baseTiles];

// this is the array that holds the columns arrays of sprites for the base layer
baseColumnArray = [[NSMutableArray alloc] init];

 // add the tiles. each x is added to a column array
for (int x=0; x<mapDimensions.x; x++){
    // position
    float xPos = TILE_WIDTH * x;

    // holds all the sprites for this column
    NSMutableArray *baseColumnSpriteArray = [[NSMutableArray alloc] init];

    // add column array reference to the base column array
    [baseColumnArray addObject:baseColumnSpriteArray];

    // now add each y tile for this x stripe to the column array
    for (int y=0; y<mapDimensions.y; y++){
        float yPos = TILE_HEIGHT * y;
        NSInteger arrayPos = getArrayPos(mapDimensions.x, x, y); // "(row * width) + col" with range checking

        // get the tile ID
        NSNumber *nsnTileID = [baseTileArray objectAtIndex:arrayPos];
        NSInteger baseID = nsnTileID.intValue;
        nsnTileID = nil;

        // if the baseID is >=0 (e.g. not -1), then create a sprite and add to the column array.
        if (baseID >= 0){
            // getSpriteFromSpritesheet is a util function that creates a sprite for this texture sheet and tile id
            CCSprite *baseSprite = getSpriteFromSpritesheet(tileTextureSheet, baseID);
            [baseSprite setPosition:ccp(xPos,yPos)];
            [baseSprite setAnchorPoint:ccp(0,0)];
            [baseSprite setVisible:TRUE];
            [baseSprite setColor:tintColorBase]; // tint the sprite
            [baseSprite setTag:xPos];
            [baseColumnSpriteArray addObject:baseSprite];
            baseSprite = nil;
        }

    } // end y

    // release the node references since they are in the array now
    [baseColumnSpriteArray release];
    baseColumnSpriteArray = nil;
} // end x

In the above method, the NSNumber values are read from the TMX map file for a tile layer and then converted into a CCSprite. Next, the CCSprite for a tile is then stored into a NSMutableArray that corresponds to a particular column in the tile layer. Finally, each column NSMutableArray is stored into a baseColumnArray.

The main reason for storing the column tile sprites into a tile column array is that it’s a simple and efficient method of managing the sprites for a particular column. If the baseColumnArray had a sprite for every tile in the map, then there would be wasted memory and graphics resources on the empty / transparent tiles. By storing only the visible tiles into a column array, then the tiles for that column are a known quantity which makes iterating on them easier while adding and removing tiles to the CCSpriteBatchNode.

I had originally tried using tags on the CCSprite to indicate the column, but the adding and removing was more costly than the above method since I had to iterate on numerous objects to find the column tiles to add or remove. By having column arrays, it was simply a matter of finding the column array, and then adding or removing those CCSprites to/from the CCSpriteBatchNode. Here is a diagram that shows the structure of the baseColumnArray:

Layer Array to Column Array to Sprite Structure

In the above example, the sprites for the base layer in the first column of the map may be found by finding the column array 1 in position 1 of the baseColumnArray, and then iterating on the sprites in the column array 1, which includes sprites for tile positions 1, 2, 3, 13, and 14. Additionally, column 5 has sprites created for tile positions 3, 4, 7 and 13, and column 9 has sprites created for tile positions 5 and 10. Here is a visualization of the tiles from the above example:

Layer Array to Column Array to Sprite Example

The same process is repeated for the Collision tile layer. Once both the Base and Collision layer arrays are created and full of sprites, the initial set of tiles, which match the device viewport tile width (e.g. 15-18 tiles wide depending on device), are added to their respective CCSpriteBatchNode. After the game tiles and objects are fully loaded, the game is started and the interval timer starts ticking.

The following method named updateCollisionTiles is called every major tick and is responsible for adding and removing columns of sprites from the CCSpriteBatchNode for the Collision layer:

-(void) updateCollisionTiles;
{
    // collision: cull those now just offscreen
    if (collisionColumnFirst < [collisionColumnArray count] && collisionColumnFirst >= 0){
        [self removeColumnArraySpritesFromLayer:collisionLayer array:collisionColumnArray first:collisionColumnFirst];
    }
    // update the current position for the first column
    collisionColumnFirst++;

    // collision: show those about to come on screen
    if (collisionColumnLast < [collisionColumnArray count] && collisionColumnLast >= 0){
        [self addColumnArraySpritesToLayer:collisionLayer array:collisionColumnArray last:collisionColumnLast];
    }
    // update the current position for the last column
    collisionColumnLast++;
}

The following method named addColumnArraySpritesToLayer is responsible for adding a set of sprites for a particular column to the CCSpriteBatchNode for a given layer (e.g. CCSpriteBatchNode for the Collision layer):

-(void) addColumnArraySpritesToLayer:(CCSpriteBatchNode *) targetLayer array:(NSMutableArray *) sourceArray last:(NSInteger) targetColumn;
{
    // take a column array from the source array
    NSMutableArray *targetColumnSingle = [sourceArray objectAtIndex:targetColumn];

    // since the layer is a Batch Node, add each ccsprite in column to parent layer
    for (int i=0; i<[targetColumnSingle count]; i++){
        // add sprite to batch node if it's not already added
        CCSprite *tileSprite = [targetColumnSingle objectAtIndex:i];

        // make sure parent is nil so this sprite isn't already added
        if (tileSprite.parent == nil){
            [targetLayer addChild:tileSprite z:0 tag:targetColumn]
        }
        tileSprite = nil;
    }
    targetColumnSingle = nil;
}

The following method named removeColumnArraySpritesFromLayer is responsible for removing a set of sprites for a particular column from the CCSpriteBatchNode for a given layer (e.g. CCSpriteBatchNode for the Collision layer):

-(void) removeColumnArraySpritesFromLayer:(CCSpriteBatchNode *) targetLayer array:(NSMutableArray *) sourceArray first:(NSInteger) targetColumn;
{
    // take a column array from the source array
    NSMutableArray *targetColumnSingle = [sourceArray objectAtIndex:targetColumn];

    // since the layer is a Batch Node, remove each ccsprite in this column from parent layer
    for (int i=0; i<[targetColumnSingle count]; i++){
        // add sprite to batch node if it's not already added
        CCSprite *tileSprite = [targetColumnSingle objectAtIndex:i];

        // make sure parent is not nil so that means it exists in the layer
        if (tileSprite.parent != nil){
            [targetLayer removeChild:tileSprite cleanup:NO];
        }
        tileSprite = nil;
    }
    targetColumnSingle = nil;
}

Note that the CCSprite memory is not being released when the CCSprite is removed from the targetLayer, since the associated sourceArray is still the true owner of that CCSprite. It’s important to make sure that all of the CCSprite objects are properly released from all of the owning source arrays and column arrays during dealloc as to not leak a ton of memory.

About 90% of Trisector’s code is designed to adhere to the ARC memory conventions, but some of these cocos2d objects needed to be specifically alloc’d and dealloc’d in order to be property retained during run time. It’s important to figure out what allocates the object and when to release that object after the level attempt has completed.

There are a bunch of tiny parts that make the engine work, but the tile and object creation and culling was the most important from a performance perspective. The node based culling shown above was refactored into a quad based culling for v1.0.1 update of Trisector. An in depth look at the node based and quad based culling methods is located here: Improved Tile Culling for a CCSpriteBatchNode.

Hope you found this article on Trisector’s Engine interesting,

Jesse from Smash/Riot

Recent Posts

About

Smash/Riot LLC focuses on interactive media, virtual experiences, games, visual effects, VR/AR, and networked experiences for modern platforms.