Lesson 7: 2D Side Scroller

This week we take a closer look at Unity’s 2D systems, and start out on our own side-scrolling adventure. Over the next two weeks, we will develop a fully functional platform game complete with hero, enemies, and so many moving platforms. Let’s begin!

Part 1: 2D Basics

An important distinction to make about building a 2D game in Unity is that it is all the same Unity game engine. There are 2D and 3D capabilities built into the engine, but up to now we have dealt exclusively in the 3D realm. To make development (and project filesizes) efficient, many of the packages that are specific to 2D are turned off in the 3D templates. The best way for us to activate these packages is to launch a new project, but use the built-in 2D Template instead.

When we launch this, our editor will look mostly the same, with a few small, but significant differences. First, you will notice that our Scene window is set to “2D” mode, with our camera facing the XY plane.

The “2D” button at the top left will toggle between 2D and 3D mode when activated.

You will also notice that our scene only include a camera – by default there is no directional light, because we will be using sprite rendering in this project instead. Also, our Main Camera is a little different – our Projection is set to Orthographic, and there is no Skybox. (There can be a skybox, but none is included by default, so the Clear Flags option falls back to the Solid Color designation.

While the 2D / Orthographic camera can be used to render 3D content – remember, we did this with our Pong game – the primary method of drawing objects on the screen for 2D content is to use Sprites.

“Sprite” is a term that has been used in the video game industry almost since it’s inception. In the beginning, it referred to an image of a specific size and type that could be drawn directly to the the screen without having to be processed by the CPU, and was used to improve the performance of a game. These days, the term refers to any bitmap (or image) that is used primarily as a 2D object in a game, rather than a texture or mesh object. 2D games still rely heavily on these for their characters and worlds.

Technically, our Sprite objects in Unity are still mesh objects. Unity generates a simple flat mesh shape, and applies the bitmap to it as a texture, but instead uses an optimized shader to draw this without considering many of the procedure we would include in 3D rendering. Even our text objects generate a simplified mesh and apply the font as a texture to it. For our purposes, however, we will refer to “mesh” objects as those belonging in the 3D realm, and our “sprite” object meshes simply as Sprites.

A “sprite sheet” refers to an image that contains a collection of images to be used as sprites. Sometimes these are collections of common objects or “tiles” that can be used to create a level, as we will do here, but more often you will encounter them as collections of sequential frames for animation. The reason that these sprite sheets are used is that it is far more costly to load a number of small images into memory and swap them in and out as objects in the scene than it is to have a single large image and instead adjust which coordinates of the image will be drawn in a particular frame.

For this demo, we are going to use three files as sprites:

Our hero, a little Viking boy…
… his enemy, a pixel dragon…
…and the pixelated land they inhabit.

In this case, we have:

  • Our hero, a little Viking Boy (a high resolution drawing, and part of a larger sprite sheet comprised of frames of animation. I’ll be showing these next week)
  • An enemy (a very small pixelated dragon, also from a sprite sheet comprised of frames of animation)
  • Environment Tiles (a 128×128 sprite sheet composed of 16×16 pixelated blocks, each one intended as a “Tile” for our Tilemap)

Sprite Layering

We start by placing an instance of our Viking Boy into the Scene. You’ll notice when we do this, that we have a new Component, the Sprite Renderer. (This replaces the Mesh Renderer and Mesh components from our 3D games.) You will also notice that the default tool to move the Sprite is the Rect Tool, located to the right of the Scale button. (The hotkey for this is: Y) You can still use move/scale/rotate, but the Rect tool is easier for 2D workflows.

Our Sprite Renderer component gives us access to only a few controls that we will use. The “flip” property is useful for swapping a sprites direction without resorting to rotation or scaling tricks. The “color” property lets you set a tint for the sprite, as well as the alpha (transparency) value. This means that any sprite can be faded. There are no special shaders like what we had to use with our 3D blocks in the last assignment.

Under Additional Settings we find perhaps the most important setting, the Sorting Layer. Unity’s sprite rendering works a little different from what you have experienced up to this point, in that it often doesn’t matter how close or far a sprite is from the camera – the order that sprites are drawn in (and therefore the determination of which sprite will be in front of the other) comes down to a series of comparisons, the most important of which is the Sorting Layer.

Here, we have our Viking Boy on the “Default” layer, but if we access the dropdown and click “Add Sorting Layer…” we go to our Tags & Layers panel and can create new layers. The order of these layers determines which will draw in front of which. Each layer on the list will draw in front of the layer before it, so the objects set to the bottom layer (here we created “Foreground”) will draw in front of everything else.

Also in our Additional Settings, you can see the Order within Layer setting. This takes a numerical value, and when two objects in the same layer overlap, the object with the higher number will render in front of the lower numbered object. (If objects are equal after this, distance to camera is finally considered)

Importing and Editing Sprite Sheets

Sprites and sprite sheets come into our game the same way as the rest of our resources – we import them using Assets> Import New Assets…

Once imported, you will want to select it so that you can make edits to the Import Settings. Remember, these assets are external to your editor. Nothing you do here will change the contents of the sprite sheet, only how the Editor interprets it. Also remember that once you have made changes you will need to confirm the changes by clicking the Apply button at the bottom of the panel.

In your Import Settings, chance the Texture Type to “Sprite (2D and UI”. This tells Unity a bit about how you intend to use the file.  If your sprite is a single image, then the default Sprite Mode setting of “Single” will work for you. However, if this is a sprite sheet, change this to “Multiple”, which lets unity know to expect more than one object will come out of this texture atlas.   Your Pixels Per Unit setting serves as the scale for how big your sprites should be if you drop them into the scene (of course you can always scale them to meet your preference). By default this value is 100 pixels to a unit. This is great for our Viking Boy as this will make him about 2 units high, but our Environment object is only using 16×16 pixels per sprite. We change this value to “16” so that each block will be 1 unit by 1 unit.

Next, we want to correct the “fuzzy” look of our smaller objects. If we post this to our level, we see the edges are blurry. This is because Unity is “sampling” the image to visualize it at a higher resolution. This is great for things like photorealistic textures, but terrible for our pixel art. To correct this, we go to the Advanced Settings and set the Filter Mode to “Point (no filter)”. This setting controls how to handle scaling images. Using “point” tells the editor that we want the images sharp edges to be preserved.

Advanced Import Settings. Don’t forget to click “Apply” when you finish making changes!

If you are using pixel art, and are still getting images that don’t look quite right (i.e. the edges are sharp, but the sprites themselves are blocky or the colors somehow.. wrong) you might be having issues with the “compression” settings. Unity automatically puts compression on all images that it imports, but for pixel art this can often create a degraded look, and the gains are super minimal because the source images are so small to begin with. To correct this, look at the Default settings at the bottom of Import Settings and set your Compression to “None”. This will remove any adjustment and use your source material as-is.

and again, make sure you hit “Apply”

Now it is time to split the sprites, which we will do with the Sprite Editor, which you access from the sprite’s Import Settings.

NOTE: If you want to use the Sprite Editor with the 3D template, you may need to install the 2D Sprite package, which you can do from the Package Manager. If you are starting from a 2D template, this package is already activated.

For our Environment sprite sheet, go to the Slice dropdown button at the top and select Grid by Cell Count. This will let you define the how the image should be split into pieces. For this object, we want to set the count to 8 columns and 8 rows. Keep the pivot at “Center” (you’ll want this for proper placement in the Tilemap.) Once you are ready, hit slice. You will see that small boxes have been drawn for these, but also that any empty square has been removed. If you update your sprite sheet with more sprites, you may need to run the Sprite Editor again to include the new content.

If you click one of these boxes, you will get a Rect controller, and call up information about the sprite itself. You can adjust values, or change the name of the individual sprite.

When you have completed your set-up & edits, click the “Apply” button in the Sprite Editor window, and then also the “Apply” button in the Import Settings. Now in the Asset window your sprite sheet will have a small arrow in a circle next to it. Clicking on this arrow will expand the list to show the individual sprites contained within this object.

Part 3: Tilemaps

Now that we have set up our sprites, it is time to build a level. While we could place individual sprites and colliders throughout the environment, Unity gives us an excellent tool for designing sprite content – the Tilemap. The Tilemap is a system of components which handles Tile assets and allows you to place them in your scene.

To create a new Tilemap, go to Create > 2D Object > Tilemap. This will also generate a Grid object, and your Tilemap will be a child of this object. (You can create more than one Tilemap, each will become a child of this Grid). This parent object has a Grid component within which you can set the size, spacing, and orientation of the Grid.

You will see this grid reflected in the Scene window…

The Tilemap object will contain the Tilemap Component and the Tilemap Renderer component. The most important setting to notice here is the Sorting Layer setting in the Tilemap Renderer. For our game, we will set up multiple Tilemaps under the same grid, and set them to different sorting layers to create foreground and background objects.

Once your Tilemap is created, you will want to create a Tile Palette – a collection of tiled sprites that you can use to populate the grid cells on your Tilemap. Access this by going to Window > 2D > Tile Palette. Once here, select “Create New Palette…” and give it a location and a name. Then you can drag sprites into this window which will create Tiles for each sprite. (You will see a window asking where you want to save these in your Asset directory. I recommend creating a new folder, as these can get pretty numerous if you are working with even a modestly sized sprite sheet.)

The Tile Palette window features a list of tools at the top. These are:

  • Select – allows you to select one or more grid cells
  • Move – allows you to relocate a tile (you must first select the tile)
  • Paint – select a palette tile or tiles and use this to paint that selection onto your scene’s tilemap.
  • Box Fill – fill an area of your tilemap with the selected tile(s) from your palette
  • Pick – click on a tile to change your active brush selection to that type of tile
  • Erase – click on a tile to remove it
  • Flood Fill – fill a large area with the active tile(s)

Below the tile tools is the Active Tilemap setting. If you have multiple tilemaps (which is common with layering) you will want to make sure that you have the correct Tilemap selected before you paint. For this reason, it is recommended to give each of your Tilemaps a unique name that identifies their purpose (such as Foreground, Background, etc)

Part 4: 2D Physics

Now it is time to make our Viking Boy move. Just like we did with objects in our 3D games, we want to apply a rigidbody object so that physics will be applied to this object. However, there is an entirely separate physics system for 2D objects, and so we must apply the Rigidbody 2D Component to our character. We also need a collider so that we have something to act as our physical volume. For this, we are going to use the Capsule Collider 2D. All of these can be found under the Physics 2D folder of components.

In order to land on our game level, we need to set colliders for our Tilemap. Rather than build out a series of individual colliders, we can apply the Tilemap Collider component to the Tilemap that holds our game tiles (in our examples case, we will use the tilemap that we have on the “default” layer). The sprite import will generate a simple collision shape based upon the transparency of the sprite, so this means that objects like our grass slopes will behave as we would expect them to.

Now we run our first test. Our Viking Boy probably falls straight onto the ground, depending on where you have placed him, but if you play around or have him land on a corner, you will see him roll off. This is because our Capsule Collider can rotate. We can fix this by selecting the Rigidbody 2D component and setting the Constraints to constrain rotation in the Z axis. (which is the only axis it can rotate in). You may also notice that our object falls… slowly. This is because we are still using the same 1 unit = 1 meter model of physics, which may be true to life, but is not how we expect our platform games to play. To correct this, set the Gravity Scale to a higher number like 3 or 4.

Character Controller 2D

For 3D projects, Unity has long featured a helpful component called the Character Controller. It’s a physics-like-but-not-quite-physics object that can be used to efficiently move a character in a level. This can be useful if you are building a first-person-shooter (FPS) style game, or a platformer with 3D characters. It allows you to pull off all of the moves that real physics NEVER would allow. But there is no built-in version of this for 2D character. Thankfully, plenty of people have tackled this problem and there are a number of excellent free resources that address this.

For this class, we are going to use the free CharacterController2D from Brackeys. (If you don’t know Brackeys, this is an outstanding resource of Unity tutorials. Just about any feature or gameplay style you could wish to build, they have an easy to follow tutorial on that subject. Sadly, they have announced they will not be making any more of these, but for the next three years this resources should still continue to be relevant.)

For setting this up, I strongly recommend watching the YouTube workshop for this class, as there are a number of necessary steps. The key steps you need to keep in mind:

  • Your player object will need two child objects to serve as positional markers for the top and bottom of your character. These are used to do a physics test to check for objects where the floor and ceiling would be. These can be completely empty, but must be placed appropriately otherwise your character may not jump as expected, or may slow down because it thinks it is hitting the ceiling.
  • These position marker objects must be associated with the CharacterController2D (“GroundCheck” and “CeilingCheck”)
  • You must set your object to a layer and tell the “What is Ground” to ignore that layer. (or better yet, only look for objects on one layer that is considered “ground”). The CC2D will use this to determine if your character has landed. This will be important for animations.
  • The CC2D uses a Move( ) command that we can access to apply side movement to our character, and crouch or jump. For crouching, there is a more complicated collider setup than what we use here. If you want to see how to use this, you can check out Brackeys’ tutorial on their character controller.

Next we create a simple movement script to also attach to the player (code for this is at the end of this post). We use the Input libraries to determine direction of movement and whether or not the “Jump” button was pressed. (This is one of a number of named buttons that have default settings in Unity. You can edit these settings in the Input Manager, which we first saw when we encountered GetAxis(“Horizontal”)).

We adjust our speed in our player script, and our Jump Force in our CC2D script until we have a motion that we like. Next we create a Physic Material 2D to provide a frictionless experience for our player so that he cannot stick to walls through mere force. (Don’t worry, our CC2D will slow us down). We apply the newly created physic material to our player Rigidbody.

Part 5: Camera Control

Now we have created a player object and let him run through our level, but very quickly the player runs out of view. There are a number of ways to address this. The simplest way to let the camera follow our player is to make the camera a child of the player object. This way, any movement of the player was automatically reflected in the camera. The end result is that the player remains perfectly still as the rest of the world moves around it.

This solution is OK, but does not really fit what we are going for. This should be a side scroller, meaning we move our camera to the side, and so we need a better solution.

Our first step is to move the camera horizontally with the player, but not vertically. We accomplish this by placing a script on the Camera object that would mirror the X value of the player object, like so:

  public GameObject player;

  void Update () {
      Vector3 playerposition = player.transform.position;
      Vector3 cameraposition = transform.position;

      cameraposition.x = playerposition.x
      transform.position = cameraposition;
  }

IMPORTANT NOTE: Although our game is 2D, our transforms are still very much 3D. This means when working with position, we always need to use Vector3 rather than Vector2 structures.

We set a public game object and assign the Player object to it in the Inspector.  Then on each frame, we find the X position and match it.  The result was just OK. The side motion worked but things feel a little jittery.  We want to give our player a little room to move away from the center and have the camera catch up, as though it were controlled by some camera person trying to keep up with the action.  To facilitate that, we used Mathf.SmoothDamp( ), a dampening function to create a gentle curve to the updating X values, giving the camera an eased, elastic feel.

     public GameObject player;
     private float xVelocity = 0.0f;

     // Update is called once per frame
     void Update () {
         Vector3 playerposition = player.transform.position;
         Vector3 cameraposition = transform.position;
 
         cameraposition.x = Mathf.SmoothDamp (cameraposition.x, playerposition.x, ref xVelocity, 0.5f);
         transform.position = cameraposition;
     }

SmoothDamp ( ) is part of the float math libraries, and takes 4 parameters – the start value, the end value, the current velocity (as a reference), and the time the smoothing should occur.  Velocity here is tricky, as SmoothDamp ( ) will modify it each time it runs.  In order to let that persist, we pass the velocity variable as a “ref”, which is the closest we will get to pointers in this class.  Normally when we call a method we say “here is a value” but in this case by declaring “ref”  we say “here is access to the variable itself”.  SmoothDamp ( ) will update velocity each time it runs.

Playing this again, this is getting better.  My player runs away and the camera catches up again.  Since I’ve decided to use “Mario” rules, I want to make sure that the player can only advance, not move backwards.  I’ll do that by defining two rules.

Rule #1: Any time the player moves right of the midpoint on the screen, the camera will follow.
Rule #2: The player can only move as far left as is currently visible on the screen.

Rule #1 is easy enough to implement.  To do this, we set a condition around the SmoothDamp and transform position update that test to see if playerposition.x is greater than cameraposition.x and if so, it will let the camera follow.

    public GameObject player;

    // ref value for smoothDamp
    private float xVelocity = 0.0f;

    // Update is called once per frame
    void Update()
    {
        // match the player x position
        Vector3 playerposition = player.transform.position;
        Vector3 cameraposition = transform.position;

        if (playerposition.x > cameraposition.x) { 
            // cameraposition.x = playerposition.x;
            cameraposition.x = Mathf.SmoothDamp(cameraposition.x, playerposition.x, ref xVelocity, 0.5f);

            transform.position = cameraposition;
        }
    }

Rule #2 is a little trickier, as we don’t really know where the left edge is.  We could do all kinds of math to figure this out, casting rays and such, but we are going to do this the lazy way.

OPTION 1: (the complicated way)  In a previous semester, we included a value called “leftSideOffset” that held the distance in x units that corresponded with the left edge of the screen from the center of the camera.  Since the camera is always the same z-distance from the player, this number can be a constant.  In our update loop, we then check the offset as a bounding x-value for the player, with the following code:  

    Vector3 checkposition = transform.position;
    float leftx = gameCamera.transform.position.x - leftSideOffset;
 
    if (checkposition.x < leftx) {
        checkposition.x = leftx;
        transform.position = checkposition;
    }

OPTION 2: (the cheap way)  For this class, I have implemented a much more rudimentary system. I create an object and assign it a BoxCollider 2D. I size this to span beyond the vertical length of my camera view, and move it to the very left of the camera. Finally, I make this object a child of the Main Camera, meaning that it will follow along wherever our camera goes. This prevents our player from being able to run beyond the edge of the screen. (Once we place enemy objects, you will want to adjust your Collision Matrix in the Physics 2D panel in your Project Settings, just like we did with our 3D collisions in Astral Attackers, so that your non-player objects can pass through unimpeded.)

That box on the left means there’s only one way you can go.

Part 6 – Enemies and Triggers

Now that we’re moving and jumping, let’s create some enemy objects to interact with.  We won’t worry about colliding with them right now, but let’s at least get them in the scene and behaving the way we expect them to behave (patrolling their platforms)!

We created an object similar to our player but with our Enemy sprite. We added CharacterController2D, Circle Collider 2D, and Rigidbody 2D component to it, and gave it a simplified version of our script to automate its movement. We use a simple boolean in our script called “FaceLeft” which will indicate if our enemy is facing left or right, and uses that value to pass that direction into the CC2D move command.

To create behaviors, we will use Triggers to give our Enemy the illusion of intelligently patrolling its platform. Triggers are a variation of colliders that don’t actually collide – they simply define a volume, and that volume will create a collison-like event when another object’s collider enters it.

For our purposes, we are going to create a “turn around” box – a cube volume that our enemy will enter, register, and react by reversing direction. First, create an empty object, add a Box Collider 2D, and check the Is Trigger box. Create a tag called “TurnAround” and assign it to this box. Make this object a prefab so that we can place them throughout the level. Then we add this to the Enemy script:

    private void OnTriggerEnter2D(Collider2D collision)
    {
        if (collision.gameObject.tag == "TurnAround")
        {
            Debug.Log("Hit the Collider");
            faceLeft = !faceLeft;
        }
        
    }

Once the Enemy object enters the trigger object, it checks for the “TurnAround” tag, and if it finds one, it will invert the “faceLeft” value of the Enemy causing him to walk in the other direction.  This will only fire when the enemy first enters the volume as OnTriggerEnter2D only occurs at the first point of overlap.  You can use OnTriggerExit2D to register when the overlap has ended, or OnTriggerStay2D to confirm that an overlap is ongoing.

NOTE: Trigger’s use the OnTrigger commands, as opposed to the OnCollision commands. Unity treats these differently, so they will only apply to the corresponding setting for isTrigger. Also, because this is 2D physics, note the 2D designation at the end of each of these. OnCollisionEnter and OnCollisionEnter2D use different physics systems and are not interchangeable, so if your collisions are not registering, check that you are using the proper command.

Part 7: Pixel Perfect Camera

When working with pixel art, you may notice some tearing or flickering lines in your game. This is often due to the slight artifacts created by the camera sampling the artwork at an incorrect distance. Thankfully, Unity includes a package to help correct for this – the 2D Pixel Perfect Camera. Rather than run through all of the features here, I will link to the excellent resource that the folks from Mega Cat Studios have put together.

2D Pixel Perfect: How to set up your Unity project for retro 8-bit games – [ Unity Blog ]


PlayerMovement.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UIElements;

public class PlayerMovement : MonoBehaviour
{
    public CharacterController2D controller;
    public float speed;

    private float horizontalMove = 0.0f;
    private bool jump = false;


    // Update is called once per frame
    void Update()
    {
        horizontalMove = Input.GetAxisRaw("Horizontal") * speed;

        if (Input.GetButtonDown("Jump"))
        {
            jump = true;
        }

    }

    private void FixedUpdate()
    {
        controller.Move(horizontalMove * Time.fixedDeltaTime, false, jump);
        jump = false;
    }

}


Enemy.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Enemy : MonoBehaviour
{
    public float speed;
    public bool faceLeft = true;

    private CharacterController2D controller;

    private void Start()
    {
        controller = GetComponent<CharacterController2D>();
    }

    // Update is called once per frame
    void FixedUpdate()
    {
        float horizontalMove = speed * Time.fixedDeltaTime;

        if (faceLeft) { horizontalMove *= -1.0f; }

        controller.Move(horizontalMove, false, false);
    }

    private void OnTriggerEnter2D(Collider2D collision)
    {
        if (collision.gameObject.tag == "TurnAround")
        {
            Debug.Log("Hit the Collider");
            faceLeft = !faceLeft;
        } else if (collision.gameObject.tag == "Player")
        {
            Debug.Log("Goodbye Cruel World!!!");
            Destroy(this.gameObject);
        }
        
    }

}


CameraFollow.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class CameraFollow : MonoBehaviour
{
    public GameObject player;

    private float xVelocity = 0.0f;

    // Update is called once per frame
    void FixedUpdate()
    {
        Vector3 playerposition = player.transform.position;
        Vector3 cameraposition = transform.position;

        // cameraposition.x = playerposition.x;
        if (playerposition.x > cameraposition.x) { 
            cameraposition.x = Mathf.SmoothDamp(cameraposition.x, playerposition.x, ref xVelocity, 0.5f);
        }

        transform.position = cameraposition;
    }
}