2D Development: Sprites, Tilemaps, 2D Physics, 2D Animation & Lights
How Unity makes 2D games: sprites and their import settings, slicing and atlases, draw order, orthographic and pixel-perfect cameras, tilemaps with palettes, Rule Tiles and code, 2D physics with Rigidbody2D, colliders, effectors and queries, a responsive platformer controller, frame-by-frame and bone animation, and URP's 2D lights and shadows. The guided build is Lantern Cliffs, a small pixel-art platformer on Lighthouse Island, with art drawn by a script so you need no assets.
- Study time
- 10–14 hours
- Reading time
- 178 min read · 13,600 words
- Prerequisites
- T05 · Scripting in Unity: Lifecycle, Serialization, Coroutines & Awaitable, T06 · Game Maths: Vectors, Trigonometry, Matrices, Quaternions & Interpolation, T07 · Input System, Movement & Cameras (Cinemachine)
- Courses merged
- 9 course files
- Your progress
- —
- Set up a 2D URP project and import pixel art or painted art with the right Pixels Per Unit, filtering, compression and pivots
- Slice sprite sheets, paint levels with tilemaps, palettes, Rule Tiles and code, and give them merged colliders
- Control what draws in front with sorting layers, order in layer, sorting groups and a custom sort axis
- Use Rigidbody2D, 2D colliders, effectors and joints, and predict which 2D collision and trigger messages fire
- Write a platformer controller that feels good: contact-filter ground checks, coyote time, buffering, variable jump height and one-way platforms
- Animate sprites frame by frame with an Animator, and know when bones and the 2D Animation package are worth it
- Light a 2D scene with URP's 2D lights, normal maps and shadow casters, and keep pixel art crisp with the Pixel Perfect Camera
A 2D game in Unity is a 3D scene seen exactly side-on. Sprites are flat pictures standing in the XY plane, the camera looks straight down the Z axis without perspective, and a separate physics engine works only in X and Y. Once that picture is clear, the rest of 2D is a set of tools on top: tilemaps for levels, sorting for what draws in front, 2D physics for movement, the Animator for frames, and 2D lights for mood.
This chapter covers all of those for Unity 6.3 with URP's 2D Renderer. Every script compiles against Unity 6.3 with no warnings and is in the sandbox under Assets/Handbook/T09. You need T05 · Scripting in Unity: Lifecycle, Serialization, Coroutines & Awaitable's lifecycle, T06 · Game Maths: Vectors, Trigonometry, Matrices, Quaternions & Interpolation's vectors and T07 · Input System, Movement & Cameras (Cinemachine)'s Input System actions. T08 · 3D Physics: Rigidbodies, Colliders, Raycasts & Character Controllers covers 3D physics in more depth; 2D physics follows the same ideas, and this chapter explains each one again in its 2D form, so you can read it without T08.
The big picture#
Unity has no separate 2D engine. A 2D project is an ordinary scene with three things set up differently:
- The camera is orthographic. It has no perspective: things don't get smaller with distance, and Z only decides what draws in front. Its Size is half the height of the view, in world units.
- The visible things are sprites. A sprite is a picture cut from a texture, drawn by a Sprite Renderer on a flat rectangle (or a tighter mesh) in the XY plane. Its Pixels Per Unit setting decides how big it is in the world.
- Physics is 2D physics.
Rigidbody2Dand the 2D colliders run in their own engine, based on Box2D, that knows only X, Y and rotation about Z. It never sees 3D colliders, and 3D physics never sees it.
Two numbers tie the picture together. Pixels Per Unit (PPU) converts sprite pixels to world units: a 16-pixel tile at 16 PPU is exactly one unit, which makes one tile one unit, and grids, physics and level design all line up. The camera's orthographic Size converts world units to the screen: Size 5 shows 10 units from the bottom of the screen to the top, however big the window is. A wider window shows more on the left and right, never more above and below.
Setting up a 2D project#
In Unity Hub, create a project from the Universal 2D template (the 2D template that uses URP). It differs from the 3D templates in several ways that matter:
- The 2D feature set is installed. Sprite tools, Tilemap and Tilemap Extras (Rule Tiles), 2D Animation (bones), the PSD and Aseprite importers, SpriteShape, Pixel Perfect and 2D Tooling. In the Package Manager they appear together as the 2D feature. The scripts and menu paths in this chapter were checked against the versions bundled with editor 6000.3.27f2, among them 2D Animation 13.0.6 and Tilemap Extras 6.0.3.
- URP uses the 2D Renderer.
Assets/SettingsholdsUniversalRPandRenderer2D. The 2D Renderer is what makes 2D lights and shadows work; with URP's ordinary renderer (Universal Renderer), Light 2D components do nothing. - New images import as sprites, and the Scene view opens in 2D mode, because Edit > Project Settings > Editor > Default Behavior Mode is set to 2D.
- The sample scene has an orthographic Main Camera (Size 5) and a Global Light 2D, a light that lights everything evenly. Delete it and every sprite using a lit material goes black.
- Physics 2D defaults (Edit > Project Settings > Physics 2D): gravity (0, −9.81), Simulation Mode Fixed Update, Queries Hit Triggers and Queries Start In Colliders both on, Callbacks On Disable on. These come up again below.
You can add 2D to an existing 3D URP project too: install the 2D feature, create a 2D Renderer asset (Assets > Create > Rendering > URP 2D Renderer), and add it to the URP asset's renderer list. A project can mix the two: a 3D game with 2D sprites in the world works with either renderer, but 2D lights need the 2D Renderer.
Sprites#
A texture is an image file. A sprite is a rectangle of that texture with a pivot and a size in world units. One texture can hold one sprite or hundreds (a sprite sheet). You choose which in the texture's import settings.
Import settings#
Select an image in the Project window. With Texture Type set to Sprite (2D and UI), these settings decide how it becomes sprites:
| Setting | What it does | Pixel art | Painted or vector art |
|---|---|---|---|
| Sprite Mode | Single: the whole image is one sprite. Multiple: a sheet you slice in the Sprite Editor. Polygon: one sprite with a polygon outline you set by side count. | Multiple for sheets | Single, or Multiple for sheets |
| Pixels Per Unit | How many image pixels make one world unit. | Your tile size: 16, 32… | Pick one value for the whole game, often 100 |
| Mesh Type | Tight draws a mesh that hugs the opaque pixels (less overdraw); Full Rect draws a plain rectangle. | Full Rect for tiles | Tight (the default) |
| Pivot | The sprite's origin: where its Transform position sits, and what it rotates and flips around. Set per sprite in the Sprite Editor. | Center for props, Bottom for characters you place on the ground | |
| Filter Mode | How pixels are smoothed when scaled. Point keeps hard pixel edges; Bilinear blends them. | Point | Bilinear |
| Compression | Smaller files and memory, at some loss of quality. | None: compression smears single pixels | Normal or High Quality |
| Generate Mip Maps | Smaller copies for distant or scaled-down drawing. | Off | Off unless you zoom out a lot |
| Max Size | Textures bigger than this are scaled down on import. | At least the image's real size, or it silently loses detail | |
Pixel art needs all three of Point filtering, no compression and a sensible PPU. Miss any one and it comes out blurred or with smeared colours. Painted art is the opposite: bilinear filtering and compression are what you want.
The Sprite Editor#
Open it with the Sprite Editor button in the texture's Inspector (or Window > 2D > Sprite Editor). Its Slice menu cuts a Multiple-mode sheet into sprites:
- Automatic finds islands of opaque pixels. Good for loose collections of props; bad for animation frames, whose sizes then differ from frame to frame.
- Grid By Cell Size cuts a regular grid, for example 16 × 16. The right choice for tiles and animation frames, because every frame gets the same size and the same pivot.
- Grid By Cell Count is the same, given rows and columns instead of sizes.
- Isometric Grid cuts diamond-shaped cells for isometric tiles.
Click Apply afterwards. In the Project window the texture then shows an arrow; expand it to see the sprites, named after the file with a number. The Sprite Editor has other modes in its top-left drop-down: Custom Outline edits the mesh that a Tight sprite draws, Custom Physics Shape sets the outline that a PolygonCollider2D takes from the sprite (and that tiles use for collision), Secondary Textures attaches normal maps for 2D lighting, and with 2D Animation installed, Skinning Editor adds bones.
Each sprite also has a Border, set in the Sprite Editor's sprite panel. It marks the edges that mustn't stretch, for 9-slicing: with a Sprite Renderer's Draw Mode set to Sliced, the corners stay the same size and only the middle stretches, so one small sprite makes a panel, a platform or a window frame of any size. Tiled mode repeats the middle instead.
The Sprite Renderer#
| Property | What it does |
|---|---|
| Sprite | Which sprite to draw. Changing it from code or from an animation is how frame-by-frame animation works. |
| Color | Multiplies the sprite's colours. White leaves them alone; the alpha fades it. A cheap way to flash a sprite red when it's hit. |
| Flip | Mirrors the picture on X or Y, around the pivot. Colliders and child objects don't flip with it. |
| Draw Mode | Simple, Sliced or Tiled (see 9-slicing above). |
| Mask Interaction | Whether a Sprite Mask hides this sprite, shows it only inside the mask, or ignores masks. |
| Sprite Sort Point | Center or Pivot: which point the sort-by-distance test measures. |
| Material | Sprite-Lit-Default reacts to 2D lights; Sprite-Unlit-Default ignores them and always shows the sprite's own colours. |
| Sorting Layer, Order in Layer | What draws in front of what. The next section. |
For quick prototypes you don't need art at all. Assets > Create > 2D > Sprites makes white placeholder sprites (Square, Circle, Capsule, Triangle, Isometric Diamond, two hexagons and a 9-Sliced one), and GameObject > 2D Object > Sprites creates an object that uses one. Tint them with Color. GameObject > 2D Object > Physics > Dynamic Sprite adds a Rigidbody2D and a collider as well.
Sprite Atlases#
Each texture is a separate thing for the GPU to bind. Draw a hundred sprites from forty textures and Unity issues dozens of draw calls; draw them from one texture and it can batch them into a few. A Sprite Atlas (Assets > Create > 2D > Sprite Atlas) packs the sprites you give it, or whole folders, into one texture when the game is built or played. Your sprites and scenes don't change: Unity draws from the atlas behind the scenes. The Universal 2D template already has the Sprite Atlas system switched on (Edit > Project Settings > Editor > Sprite Atlas).
Group sprites that appear together: one atlas for the forest level, one for the UI, one for the hero. An atlas for everything forces every level to load every sprite into memory. Sprites packed into an atlas use the atlas's filter and compression settings, so a pixel-art atlas needs Point filtering and no compression too.
Sorting: what draws in front#
Sprites are transparent, so they're drawn back to front: each one paints over what's already there. Unity decides the order for every pair of sprites with these tests, in order. The first test that tells them apart wins:
Sorting layers are the big buckets, set up in Edit > Project Settings > Tags and Layers > Sorting Layers: for example Background, Level, Characters, Foreground. Layers lower in the list draw in front. They have nothing to do with the physics layers in the same window: a sprite can be on the Characters sorting layer and the Enemies physics layer. Order in Layer sorts within a layer. Leave gaps (0, 10, 20) so you can slot things in later without renumbering.
The third test matters for top-down and three-quarter-view games, where something lower on the screen is nearer the viewer and should draw in front. Set Transparency Sort Mode to Custom Axis and the axis to (0, 1, 0) on the 2D Renderer asset (Renderer2D, under Transparency Sort Mode). Sprites then sort by their Y position: higher up is further back. Set each character's Sprite Sort Point to Pivot and put the pivot at their feet, so the test uses where they stand rather than the middle of the picture.
A character made of several sprites (body, arm, sword, shadow) has a problem with the third test: when two characters overlap, their parts sort individually and interleave, and one's arm appears through the other's body. Add a Sorting Group to the character's root. Its children then sort as one unit against everything else, and by their own layers and orders inside the group.
A Sprite Mask (GameObject > 2D Object > Sprite Mask) uses a sprite's shape to show or hide other sprites, chosen by each one's Mask Interaction: a window you see a room through, a keyhole, a health bar that fills. It doesn't draw anything itself.
Cameras for 2D#
For pixel art, the camera's Size has to match the art, or pixels come out uneven: some screen columns get two copies of an art pixel and their neighbours get three, and a scrolling scene shimmers. The rule: the view should be a whole number of screen pixels per art pixel. For a game designed at a reference resolution (the art's own screen size, for example 320 × 180), the orthographic Size is that height divided by twice the PPU. The program below works out the sizes, and how a 320 × 180 game fits common screens:
using System;
// The numbers behind a pixel-art platformer: camera size, whole-number scaling,
// and jumps with a gravity scale. Pure C#, so it runs without Unity.
static class Program
{
const int PixelsPerUnit = 16;
const double Gravity = 9.81;
static void Main()
{
Console.WriteLine($"Orthographic size for pixel art ({PixelsPerUnit} pixels per unit)");
foreach (var (w, h) in new[] { (320, 180), (384, 216), (480, 270) })
{
double size = h / (2.0 * PixelsPerUnit); // half the view's height, in units
Console.WriteLine($"{w} x {h} art: size {size:F3}, view {w / (double)PixelsPerUnit:F1} x {h / (double)PixelsPerUnit:F2} units");
}
Console.WriteLine();
Console.WriteLine("Whole-number scaling of a 320 x 180 game");
foreach (var (w, h) in new[] { (1920, 1080), (2560, 1440), (1366, 768), (1280, 800) })
{
int scale = Math.Min(w / 320, h / 180); // the biggest whole number that fits
int drawnW = 320 * scale, drawnH = 180 * scale;
Console.WriteLine($"{w,4} x {h,4}: x{scale}, draws {drawnW,4} x {drawnH,4}, border {(w - drawnW) / 2} x {(h - drawnH) / 2}");
}
double scaleOfGravity = 3;
double g = Gravity * scaleOfGravity;
Console.WriteLine();
Console.WriteLine($"Jumps with gravity scale {scaleOfGravity} (g = {g:F2})");
foreach (double height in new[] { 1.0, 2.0, 3.5 })
{
double speed = Math.Sqrt(2 * g * height);
Console.WriteLine($"height {height:F1}: take-off speed {speed,5:F2}, rises for {speed / g:F3} s");
}
double full = Math.Sqrt(2 * g * 3.5);
double cut = full * 0.5;
Console.WriteLine($"height 3.5, Jump let go at once: {cut * cut / (2 * g):F3} high");
Console.WriteLine();
Console.WriteLine("A 3.5-high jump at 7 units/s, landing at the height it started");
foreach (double fallMultiplier in new[] { 1.0, 1.8 })
{
double up = full / g;
double down = Math.Sqrt(2 * 3.5 / (g * fallMultiplier));
Console.WriteLine($"fall gravity x{fallMultiplier:F1}: in the air {up + down:F3} s, covers {7 * (up + down):F2} units");
}
Console.WriteLine();
Console.WriteLine("Coyote time at 7 units/s");
foreach (double coyote in new[] { 0.05, 0.1, 0.15 })
Console.WriteLine($"{coyote:F2} s: a jump still works {7 * coyote:F2} units past the edge ({7 * coyote * PixelsPerUnit:F0} pixels)");
}
}Orthographic size for pixel art (16 pixels per unit)
320 x 180 art: size 5.625, view 20.0 x 11.25 units
384 x 216 art: size 6.750, view 24.0 x 13.50 units
480 x 270 art: size 8.438, view 30.0 x 16.88 units
Whole-number scaling of a 320 x 180 game
1920 x 1080: x6, draws 1920 x 1080, border 0 x 0
2560 x 1440: x8, draws 2560 x 1440, border 0 x 0
1366 x 768: x4, draws 1280 x 720, border 43 x 24
1280 x 800: x4, draws 1280 x 720, border 0 x 40
Jumps with gravity scale 3 (g = 29.43)
height 1.0: take-off speed 7.67, rises for 0.261 s
height 2.0: take-off speed 10.85, rises for 0.369 s
height 3.5: take-off speed 14.35, rises for 0.488 s
height 3.5, Jump let go at once: 0.875 high
A 3.5-high jump at 7 units/s, landing at the height it started
fall gravity x1.0: in the air 0.975 s, covers 6.83 units
fall gravity x1.8: in the air 0.851 s, covers 5.96 units
Coyote time at 7 units/s
0.05 s: a jump still works 0.35 units past the edge (6 pixels)
0.10 s: a jump still works 0.70 units past the edge (11 pixels)
0.15 s: a jump still works 1.05 units past the edge (17 pixels)A 1920 × 1080 screen fits the 320 × 180 game exactly six times. A 1366 × 768 laptop fits it four times, with a 43-pixel border left and right and 24 above and below; five times would need 1600 × 900. You either accept the border, or crop a little of the view, or let the scale be uneven and live with the shimmer. The other numbers in the output are for the platformer later in the chapter.
URP's Pixel Perfect Camera component (Add Component > Rendering > 2D > Pixel Perfect Camera, on the camera) does this sum for you every frame and sets the camera's Size itself. Set Assets Pixels Per Unit to your PPU and Reference Resolution to the art's resolution. Its other settings:
- Crop Frame: what to do with the leftover border. None shows a bit more of the world in it; Pillarbox, Letterbox and Windowbox fill it with black; Stretch Fill scales up to fill the screen at the cost of uneven pixels.
- Grid Snapping: Pixel Snapping snaps sprites to the art's pixel grid as they're drawn, so a sprite never sits between two pixels. Upscale Render Texture renders the scene at the reference resolution and scales the whole image up, which also turns rotated sprites into honest pixel art, at the cost of jerkier movement for slow-moving things.
Cinemachine works with it too: add the Cinemachine Pixel Perfect extension to the Cinemachine Camera, and use Cinemachine Confiner 2D with a PolygonCollider2D to keep the view inside the level. T07 · Input System, Movement & Cameras (Cinemachine) covers Cinemachine; the guided build uses a short script instead, so you can see the clamping maths.
Tilemaps#
Placing a hundred sprites by hand to build a level is slow, and moving one block means moving every object. A Tilemap stores a grid of cells instead, each holding a reference to a tile asset, and draws them all with one renderer. You paint levels like pixels in a paint program.
The parts#
- Grid: a parent object with the cell layout (Rectangle, Hexagon, Isometric or Isometric Z as Y), cell size and gap. Every tilemap under it shares its grid.
- Tilemap + Tilemap Renderer: one layer of cells, usually one child of the Grid per purpose: background, ground, platforms, decorations. Each has its own sorting layer and order, and its own colliders or none.
- Tile: an asset that says what a cell shows: a sprite, a colour, a Collider Type (None, Sprite or Grid) and flags. Rule Tiles and Animated Tiles are tiles with more logic.
- Tile Palette: an editor window (Window > 2D > Tile Palette) holding the tiles you paint with.
GameObject > 2D Object > Tilemap > Rectangular creates a Grid with one Tilemap child. Right-click the Grid and use the same menu to add more layers.
Painting with the Tile Palette#
- Open Window > 2D > Tile Palette. Click the palette drop-down, then Create New Tile Palette, name it and choose a folder for it.
- Drag a sliced sprite sheet (or several sprites) from the Project window into the palette. Unity asks for a folder and creates one Tile asset per sprite.
- Choose the target tilemap in the Active Tilemap drop-down, pick a tile, and paint in the Scene view.
The tools along the top: Paint (B), Box Fill (U), Pick (I, or hold Ctrl while painting) to sample a tile or a block of tiles from the scene, Erase (D, or Shift-click), Flood Fill (G), Select (S) and Move (M). Pick with a drag samples a whole block, which you then stamp elsewhere: the quickest way to repeat a structure. [ and ] rotate the brush, Shift+[ flips it.
Rule Tiles and Animated Tiles#
A plain tile always shows the same sprite, so a ground made of plain tiles needs you to choose the right edge, corner and grass-top piece for every cell. A Rule Tile (Assets > Create > 2D > Tiles > Rule Tile, from Tilemap Extras) chooses for you. Each rule in its list is a 3 × 3 pattern: for each neighbour, "must be this tile" (green arrow), "must not be" (red cross) or "don't care". The first rule whose pattern matches the cell's neighbours decides its sprite. You paint with the one Rule Tile and the edges sort themselves out as you go.
Rules can also flip or rotate the pattern, so one corner rule covers all four corners, and an Output of Random or Animation gives variety. A Rule Override Tile reuses a Rule Tile's rules with different sprites (a snow version of the grass ground). An Animated Tile cycles through sprites at a set speed: water, torches, conveyor belts. The guided build's CliffLayout script does one small rule in code (rock with nothing above it gets the mossy sprite), which is exactly what a Rule Tile does in the editor.
Tilemaps from code#
Cells are addressed by Vector3Int (Z is for stacking, normally 0). The grid converts between world positions and cells:
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.Tilemaps;
namespace Handbook.T09
{
// Click to paint a tile under the mouse, right-click to remove it.
// Shows the round trip: screen -> world -> cell, then SetTile.
public class TileStamp : MonoBehaviour
{
[SerializeField] Tilemap tilemap;
[SerializeField] TileBase tile;
void Update()
{
Mouse mouse = Mouse.current;
if (mouse == null) return;
bool paint = mouse.leftButton.wasPressedThisFrame;
bool erase = mouse.rightButton.wasPressedThisFrame;
if (!paint && !erase) return;
Vector3 world = Camera.main.ScreenToWorldPoint(mouse.position.ReadValue());
Vector3Int cell = tilemap.WorldToCell(world);
if (paint && !tilemap.HasTile(cell))
tilemap.SetTile(cell, tile);
else if (erase)
tilemap.SetTile(cell, null); // null clears the cell
Debug.Log($"Cell {cell}: {(tilemap.HasTile(cell) ? tilemap.GetTile(cell).name : "empty")}");
}
}
}The rest of the API you'll use most: GetTile(cell) and GetTile<T>(cell) read a cell; HasTile tests it; GetCellCenterWorld(cell) gives the middle of a cell, for placing things; BoxFill, SwapTile (change every tile of one kind) and ClearAllTiles work in bulk; SetTiles with arrays is much faster than calling SetTile in a loop for big changes; cellBounds and localBounds give the area in use, after CompressBounds() trims it to the painted cells. Changing a tile at run time updates the renderer and the collider by itself.
The Tilemap Renderer#
Its Mode decides how cells are batched:
- Chunk (the default): cells are grouped into chunks and drawn together. The fastest for big static levels, but sprites in a chunk must share a texture (or an atlas) to batch, and a chunk sorts as one item, so a character can't walk between two tiles of the same tilemap.
- Individual: each tile is sorted on its own, like separate sprites. Needed when characters walk among tall tiles in a top-down or isometric game; costs more.
- SRP Batch: chunks that the Scriptable Render Pipeline's batcher can combine further. Worth comparing with Chunk in the Frame Debugger on a big level.
Colliders for tilemaps#
A Tilemap Collider 2D on a tilemap gives every tile with a Collider Type of Sprite or Grid its own shape. That works, but a floor of 40 tiles becomes 40 boxes, with seams between them that a sliding character can catch on, and every change rebuilds them. A Composite Collider 2D merges them:
- Add Tilemap Collider 2D to the tilemap.
- Add Composite Collider 2D. It requires a Rigidbody2D and adds one: set its Body Type to Static, or the whole level falls under gravity when you press Play.
- On the Tilemap Collider 2D, set Composite Operation to Merge.
The Composite Collider 2D's Geometry Type is Outlines (hollow edges, the default; best for ground you stand on) or Polygons (filled shapes; needed if queries such as OverlapPoint must detect points inside the rock). Its Generation Type is Synchronous (rebuilt as soon as a tile changes) or Manual (rebuilt when you call GenerateGeometry(), for batches of changes).
2D physics#
The 2D physics engine is separate from the 3D one: its own components, its own settings page, its own messages and queries, all ending in 2D. A BoxCollider and a BoxCollider2D never touch, and Physics.Raycast never sees 2D colliders. Mixing the two by accident is one of the most common 2D bugs.
Rigidbody2D and body types#
The same three roles as in 3D, chosen with one drop-down, Body Type:
| Body Type | Moved by | Pushed by others | Typical use |
|---|---|---|---|
| Dynamic | Physics: gravity, forces, collisions. You set velocity or add forces. | Yes | The player, crates, balls, ragdolls |
| Kinematic | Your script, with MovePosition, MoveRotation or velocity. No gravity. | No; it pushes dynamic bodies | Moving platforms, patrolling enemies, doors |
| Static | Nothing | No | Level geometry that needs a body, such as a Composite Collider's |
A collider with no Rigidbody2D at all is static too, and that's right for most scenery. The Static body type exists for components that need a body, like the Composite Collider 2D. The settings you'll change most on a Dynamic body:
| Setting | What it does |
|---|---|
| Gravity Scale | Multiplies the global gravity for this body. 0 for top-down games and floating things; 2 to 4 for snappy platformers. |
| Linear Damping, Angular Damping | Slow the body down over time, like air resistance. |
| Collision Detection | Discrete checks at each step's end position; Continuous sweeps the path, so fast bodies don't pass through thin walls. Use Continuous for the player and anything fast. |
| Interpolate | Draws the body between physics steps so it moves smoothly when the frame rate is higher than the physics rate. Turn it on for anything the camera follows. |
| Constraints | Freeze Rotation Z stops a character tipping over; Freeze Position X or Y locks movement to one axis. |
| Sleeping Mode | Whether the body starts awake. Bodies that stop moving go to sleep and cost almost nothing until something wakes them. |
| Simulated | Untick to take the body and all its colliders out of the physics world without disabling the object. |
Physics runs in fixed steps, 50 a second by default, separately from the frame rate. So the rules from 3D apply: change physics state (velocity, forces, MovePosition) in FixedUpdate; read one-off input such as a jump press in Update, store it, and use it in the next FixedUpdate, because at high frame rates most frames have no physics step at all. T08 · 3D Physics: Rigidbodies, Colliders, Raycasts & Character Controllers explains the fixed step in detail. In 2D, Simulation Mode in the Physics 2D settings can also be Update (one variable step per frame, simpler and less stable) or Script (you call Physics2D.Simulate).
Moving a dynamic body has the same choices as in 3D. Set linearVelocity (or one axis of it) for direct, responsive control, as character controllers do. Use AddForce for pushes that should respect mass: ForceMode2D.Force spreads it over the step, ForceMode2D.Impulse applies it at once. Never move a dynamic body by setting transform.position every step: physics then teleports it, it can end up inside walls, and its collisions go wrong. A minimal top-down mover:
using UnityEngine;
using UnityEngine.InputSystem;
namespace Handbook.T09
{
// Top-down movement on a dynamic Rigidbody2D with no gravity: walls stop it,
// and it pushes crates and other dynamic bodies out of the way.
[RequireComponent(typeof(Rigidbody2D))]
public class TopDownMover : MonoBehaviour
{
[SerializeField] float speed = 5f;
[SerializeField] float acceleration = 40f;
Rigidbody2D body;
InputAction move;
Vector2 input;
void Awake()
{
body = GetComponent<Rigidbody2D>();
body.gravityScale = 0f; // nothing falls in a top-down world
body.constraints = RigidbodyConstraints2D.FreezeRotation;
move = InputSystem.actions.FindAction("Player/Move", throwIfNotFound: true);
}
void Update() => input = Vector2.ClampMagnitude(move.ReadValue<Vector2>(), 1f);
void FixedUpdate()
{
Vector2 target = input * speed;
body.linearVelocity = Vector2.MoveTowards(body.linearVelocity, target, acceleration * Time.fixedDeltaTime);
}
}
}2D colliders#
| Collider | Shape | Good for |
|---|---|---|
| Box Collider 2D | A rectangle, with an optional Edge Radius that rounds its corners | Crates, platforms, simple characters |
| Circle Collider 2D | A circle | Balls, coins, pickup zones |
| Capsule Collider 2D | A rectangle with rounded ends, vertical or horizontal | Characters: the round bottom slides over bumps and tile seams |
| Polygon Collider 2D | An outline, taken from the sprite's physics shape or edited by hand | Irregular props |
| Edge Collider 2D | A line of connected points, not closed | Hills, one-sided ground, ropes of terrain |
| Tilemap Collider 2D | One shape per tile | Tile levels, usually merged by a Composite |
| Composite Collider 2D | Merges other colliders on the same body | Tile levels, compound scenery |
| Custom Collider 2D | Shapes you build from code | Procedural shapes |
Colliders on child objects belong to the nearest Rigidbody2D above them, and move with it as one shape. Every collider has Is Trigger, a Material slot, an Offset, and a Layer Overrides section for per-collider include and exclude layers. A Physics Material 2D (Assets > Create > 2D > Physics Material 2D) sets Friction and Bounciness; in Unity 6 it also has Friction Combine and Bounce Combine, like 3D's materials. A material on the Rigidbody2D applies to all of its colliders that have none of their own.
Collision and trigger messages#
The messages are the 3D ones with 2D on the end, and they take different types: OnCollisionEnter2D(Collision2D), OnCollisionStay2D, OnCollisionExit2D, and OnTriggerEnter2D(Collider2D other), OnTriggerStay2D, OnTriggerExit2D. They're sent to scripts on both objects (and, for a body with child colliders, on the body's object too). A OnCollisionEnter with no 2D on the end is never called by 2D physics, and Unity doesn't warn you: the method is legal C#, it just never runs.
Whether two colliders produce any contact at all depends on their bodies:
So a patrolling enemy on a Kinematic body collides with the Dynamic player (good) but gets no messages from static trigger zones or other kinematic enemies unless you tick Use Full Kinematic Contacts on its Rigidbody2D. That setting adds contacts and messages; kinematic bodies still aren't pushed by anything.
A Collision2D tells you what was hit (collider, rigidbody, gameObject), how hard (relativeVelocity), and where: GetContact(i) returns a ContactPoint2D with point, normal and normalImpulse, and contactCount says how many there are. Use GetContact rather than the contacts array, which allocates a new array each time you read it.
using UnityEngine;
namespace Handbook.T09
{
// A pinball bumper: anything dynamic that hits it is thrown away from its centre.
[RequireComponent(typeof(Collider2D))]
public class Bumper2D : MonoBehaviour
{
[SerializeField] float kick = 8f; // change in speed, units per second
[SerializeField] float minImpactSpeed = 0.5f;
void OnCollisionEnter2D(Collision2D collision)
{
Rigidbody2D other = collision.rigidbody; // null if the other collider has no body
if (other == null || other.bodyType != RigidbodyType2D.Dynamic) return;
if (collision.relativeVelocity.magnitude < minImpactSpeed) return;
ContactPoint2D contact = collision.GetContact(0);
Vector2 away = (other.position - (Vector2)transform.position).normalized;
other.AddForce(away * kick * other.mass, ForceMode2D.Impulse); // impulse = mass x speed change
Debug.DrawRay(contact.point, away, Color.yellow, 1f);
Debug.Log($"{other.name} hit {name} at {collision.relativeVelocity.magnitude:F1} units/s");
}
}
}Two Physics 2D settings change the messages. Callbacks On Disable (on by default) sends the Exit messages when a collider is disabled or destroyed while touching, so a "standing in the zone" counter doesn't get stuck. With it off, they're never sent. Reuse Collision Callbacks (on) reuses one Collision2D object between calls, so don't keep a reference to it after the method returns.
Layers in 2D#
2D physics has its own Layer Collision Matrix, on the Physics 2D settings page, separate from the 3D one. The layers themselves are shared (the same names in Tags and Layers), but whether Player and Enemy collide is set twice, once per engine. Layer masks work as in 3D: a LayerMask field in the Inspector, or LayerMask.GetMask("Ground", "Planks") in code.
Asking the world: 2D queries#
Physics2D has the 2D versions of the 3D queries: Raycast, Linecast (between two points), CircleCast, BoxCast and CapsuleCast (sweep a shape), and OverlapPoint, OverlapCircle, OverlapBox, OverlapCapsule and OverlapArea. They differ from 3D in three ways that catch people out:
- A 2D raycast returns a
RaycastHit2D, not a bool. It converts totruewhen something was hit, soif (Physics2D.Raycast(…))works, and you readhit.collider,hit.point,hit.normal,hit.distanceandhit.fractionfrom it. - Queries start inside colliders. With Queries Start In Colliders on (the default), a ray that starts inside your own collider hits it first, at distance 0. Either keep your own layer out of the mask, as every script in this chapter does, or turn the setting off.
- Triggers are hit by default. Queries Hit Triggers is on, so pickups and zones block your ground check. Use a mask, or a
ContactFilter2DwithuseTriggers = false.
For queries that can find many colliders, use the overloads that take a ContactFilter2D and a List (or an array) you keep and reuse. They fill your list instead of allocating a new array every call. The old OverlapCircleNonAlloc-style methods still compile in 6.3 but are obsolete:
using System.Collections.Generic;
using UnityEngine;
namespace Handbook.T09
{
// Lists what's within a radius, a few times a second, without making garbage:
// the ContactFilter2D and the List are made once and reused.
public class ProximitySensor2D : MonoBehaviour
{
[SerializeField] float radius = 3f;
[SerializeField] LayerMask layers = ~0;
[SerializeField] float interval = 0.25f;
readonly List<Collider2D> found = new List<Collider2D>();
ContactFilter2D filter;
float nextCheck;
void Awake()
{
filter = new ContactFilter2D();
filter.SetLayerMask(layers);
filter.useTriggers = false;
}
void Update()
{
if (Time.time < nextCheck) return;
nextCheck = Time.time + interval;
int count = Physics2D.OverlapCircle(transform.position, radius, filter, found);
for (int i = 0; i < count; i++)
{
Collider2D other = found[i];
if (other.attachedRigidbody != null && other.attachedRigidbody.transform == transform) continue; // skip ourselves
float distance = Vector2.Distance(transform.position, other.ClosestPoint(transform.position));
Debug.Log($"{other.name} is {distance:F1} units away");
}
}
void OnDrawGizmosSelected()
{
Gizmos.color = Color.cyan;
Gizmos.DrawWireSphere(transform.position, radius);
}
}
}A ContactFilter2D can filter by layer mask, depth (Z), triggers, and contact normal angle, which makes it the neatest ground check in 2D. rb.IsTouching(filter) asks whether any of the body's colliders is touching anything that passes the filter, using contacts the physics step has already worked out, so it costs almost nothing. With the normal angle limited to 45°–135° (90° is straight up), only surfaces underneath count: walls and ceilings don't make you grounded. GetContacts(filter, list) returns those contacts or colliders. And rb.Cast(dir, filter, hits, distance) sweeps all of a body's colliders along a direction, which is how kinematic characters find out how far they can move.
Effectors and joints#
An effector changes how colliders behave when things touch them, with no code. Add one next to a collider and tick that collider's Used By Effector (on a Composite Collider 2D, tick it on the composite).
| Effector | What it does | Example |
|---|---|---|
| Platform Effector 2D | Use One Way: solid from above, passable from below and the sides. Surface Arc sets how wide "above" is (180° by default); Rotational Offset turns it. | Planks you jump up through |
| Surface Effector 2D | Pushes things along its surface at a set speed | Conveyor belts, currents, escalators |
| Area Effector 2D | A force in a direction inside a trigger area | Wind, fans, water currents |
| Point Effector 2D | Attracts or repels from a point, optionally falling off with distance | Magnets, black holes, explosions |
| Buoyancy Effector 2D | Floats things in a fluid up to a surface level, with fluid drag and flow | Water, lava |
Area, Point and Buoyancy effectors work with trigger colliders; Platform and Surface effectors with solid ones. Joints connect two bodies, or a body and a point in the world: Hinge Joint 2D (a pivot, with limits and a motor: doors, flippers, pendulums), Distance Joint 2D (a rope or rod of fixed length), Spring Joint 2D (a springy rope), Slider Joint 2D (along a line: lifts, pistons), Wheel Joint 2D (a wheel on suspension: cars), Fixed Joint 2D (glue that can break), Relative Joint 2D (keep an offset, softly), Friction Joint 2D (slow down to a stop, for top-down sliding) and Target Joint 2D (pull towards a point: dragging things with the mouse). Each has a Break Force, and OnJointBreak2D tells you when one breaks.
A platformer character#
Most 2D platformers use a Dynamic Rigidbody2D whose velocity the script sets: physics handles collisions with walls, slopes and moving things, and the script decides the speed. (The alternative, a Kinematic body moved with Cast or Rigidbody2D.Slide, gives total control and is how many precise platformers work, at the cost of writing your own collision response.) What makes a platformer feel good is a handful of small forgivenesses, the same ones T07 · Input System, Movement & Cameras (Cinemachine) added to the 3D keeper:
- Acceleration: reaching full speed in about a tenth of a second, not instantly, so movement has weight without feeling slow.
- A jump of a set height: the take-off speed comes from the height you want, √(2 g h), so you design in tiles, not in speeds.
- Coyote time: a jump still works for a moment after running off an edge. At 7 units a second, 0.1 s is 0.7 of a tile.
- Jump buffering: a press just before landing still jumps when you land.
- Variable height: letting go of Jump early cuts the upward speed, for a short hop. Halving the speed quarters the height.
- Heavier falling: more gravity on the way down makes jumps feel snappy instead of floaty, and shortens them a little.
using UnityEngine;
using UnityEngine.InputSystem;
namespace Handbook.T09
{
// Side-on platformer movement for a dynamic Rigidbody2D: running with acceleration,
// a jump of a set height, coyote time, jump buffering, a lower jump when Jump is let go
// early, and falling faster than rising. Down + Jump is left free for dropping through planks.
[RequireComponent(typeof(Rigidbody2D))]
public class CliffRunner : MonoBehaviour
{
[Header("Running")]
[SerializeField] float runSpeed = 7f; // units (tiles) per second
[SerializeField] float groundAcceleration = 70f;
[SerializeField] float airAcceleration = 35f;
[Header("Jumping")]
[SerializeField] float jumpHeight = 3.5f; // units, at the body's starting gravity scale
[SerializeField] float coyoteTime = 0.1f;
[SerializeField] float jumpBuffer = 0.12f;
[SerializeField] float jumpCutMultiplier = 0.5f; // vertical speed kept when Jump is let go
[SerializeField] float fallGravityMultiplier = 1.8f;
[SerializeField] float maxFallSpeed = 20f;
[Header("Ground")]
[SerializeField] LayerMask groundLayers = ~0;
public bool Grounded { get; private set; }
public float FacingSign { get; private set; } = 1f;
public Vector2 MoveInput { get; private set; }
public event System.Action Jumped; // raised on every jump the runner starts
Rigidbody2D body;
InputAction move, jump;
ContactFilter2D groundFilter;
float baseGravityScale;
float lastGroundedTime = float.NegativeInfinity;
float lastJumpPressTime = float.NegativeInfinity;
bool rising;
bool jumpReleased;
void Awake()
{
body = GetComponent<Rigidbody2D>();
baseGravityScale = body.gravityScale;
move = InputSystem.actions.FindAction("Player/Move", throwIfNotFound: true);
jump = InputSystem.actions.FindAction("Player/Jump", throwIfNotFound: true);
// Ground: a contact on the ground layers whose normal points mostly up.
// 90 degrees is straight up, so 45 to 135 accepts slopes up to 45 degrees.
groundFilter = new ContactFilter2D();
groundFilter.SetLayerMask(groundLayers);
groundFilter.SetNormalAngle(45f, 135f);
}
void Update()
{
MoveInput = move.ReadValue<Vector2>();
if (MoveInput.x > 0.1f) FacingSign = 1f;
else if (MoveInput.x < -0.1f) FacingSign = -1f;
if (jump.WasPressedThisFrame() && MoveInput.y > -0.5f)
lastJumpPressTime = Time.time;
if (jump.WasReleasedThisFrame())
jumpReleased = true;
}
void FixedUpdate()
{
Grounded = body.IsTouching(groundFilter);
if (Grounded && body.linearVelocityY <= 0.01f)
{
lastGroundedTime = Time.time;
rising = false;
}
// Run: move the horizontal speed towards the target, a little at a time.
float target = MoveInput.x * runSpeed;
float acceleration = Grounded ? groundAcceleration : airAcceleration;
body.linearVelocityX = Mathf.MoveTowards(body.linearVelocityX, target, acceleration * Time.fixedDeltaTime);
// Jump if a recent press meets recent ground, in either order.
bool pressedRecently = Time.time - lastJumpPressTime <= jumpBuffer;
bool groundedRecently = Time.time - lastGroundedTime <= coyoteTime;
if (pressedRecently && groundedRecently)
{
float gravity = -Physics2D.gravity.y * baseGravityScale;
body.linearVelocityY = Mathf.Sqrt(2f * gravity * jumpHeight);
lastJumpPressTime = float.NegativeInfinity;
lastGroundedTime = float.NegativeInfinity;
rising = true;
Jumped?.Invoke();
}
// Let go of Jump while rising: keep only part of the upward speed.
if (jumpReleased)
{
if (rising && body.linearVelocityY > 0f)
body.linearVelocityY *= jumpCutMultiplier;
jumpReleased = false;
}
// Heavier on the way down, with a top speed.
body.gravityScale = body.linearVelocityY < 0f
? baseGravityScale * fallGravityMultiplier
: baseGravityScale;
if (body.linearVelocityY < -maxFallSpeed)
body.linearVelocityY = -maxFallSpeed;
}
}
}The output of PixelMaths earlier has the numbers for these settings. With Gravity Scale 3, a 3.5-tile jump needs a take-off speed of 14.35 units a second and rises for under half a second. Let go of Jump at once and the hop is 0.875 high, a quarter of the full height. The heavier fall cuts the time in the air from 0.975 s to 0.851 s, and the distance covered at full running speed from 6.83 tiles to 5.96: design your gaps for the real number, not the floaty one.
The ground check is body.IsTouching(groundFilter), with the filter limited to the ground layers and to contact normals between 45° and 135°. A wall beside the keeper has a normal pointing sideways (0° or 180°), so it doesn't count; neither does the ceiling. The test also ignores one-way planks while the keeper passes up through them, because the Platform Effector removes those contacts.
One-way platforms, both ways#
A Platform Effector 2D lets you jump up through a plank. Getting down through it needs code, because from above it's solid. The neat way is to make the plank and the character ignore each other for a moment, with Physics2D.IgnoreCollision. That affects only that pair, so other characters still stand on it:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
namespace Handbook.T09
{
// Down + Jump drops the keeper through the one-way plank they're standing on,
// by ignoring collisions between the two colliders for a moment.
[RequireComponent(typeof(CliffRunner))]
public class PlankDrop : MonoBehaviour
{
[SerializeField] LayerMask plankLayers;
[SerializeField] float passTime = 0.25f;
CliffRunner runner;
Collider2D feet;
InputAction jump;
ContactFilter2D plankFilter;
readonly List<Collider2D> planksTouched = new List<Collider2D>();
void Awake()
{
runner = GetComponent<CliffRunner>();
feet = GetComponent<Collider2D>();
jump = InputSystem.actions.FindAction("Player/Jump", throwIfNotFound: true);
plankFilter = new ContactFilter2D();
plankFilter.SetLayerMask(plankLayers);
}
void Update()
{
if (!jump.WasPressedThisFrame() || runner.MoveInput.y > -0.5f) return;
// Every plank collider the feet are touching right now.
if (feet.GetContacts(plankFilter, planksTouched) == 0) return;
foreach (Collider2D plank in planksTouched)
StartCoroutine(PassThrough(plank));
}
IEnumerator PassThrough(Collider2D plank)
{
Physics2D.IgnoreCollision(feet, plank, true);
yield return new WaitForSeconds(passTime);
Physics2D.IgnoreCollision(feet, plank, false);
}
}
}A quarter of a second is long enough to fall clear of a plank and short enough that the keeper can't drop through the plank below it as well. CliffRunner ignores Jump presses while Down is held, so Down + Jump means "drop" and never "jump".
2D animation#
There are two ways to animate a 2D character. Frame by frame: draw each pose and swap the sprite many times a second, like a flip book. Bones: cut the character into parts, give it a skeleton, and move the bones, like a puppet. Pixel art is nearly always frame by frame. Large painted characters with many moves often use bones, because one drawing then serves every animation.
Frame by frame with the Animator#
An Animation Clip stores values over time; for a sprite, the value is the Sprite Renderer's Sprite. An Animator Controller holds a set of clips as states, with transitions between them that fire on parameters your script sets. An Animator component on the object plays the controller.
- Select the character in the Hierarchy and open Window > Animation > Animation. Click Create and save the first clip, for example
Idle.anim. Unity also creates an Animator Controller and adds an Animator to the object. - Drag the clip's frames from the Project window onto the timeline. Each becomes a keyframe.
- Set the frame rate. The Samples field is hidden by default: open the Animation window's ⋮ menu and choose Show Sample Rate. At 10 samples, each keyframe lasts a tenth of a second.
- Add more clips with the clip drop-down's Create New Clip.
Shortcut: select several sprites in the Project window and drag them into the Scene view or onto an object. Unity makes a clip from them (and a controller, if the object has none).
Then wire the states up in Window > Animation > Animator. Add parameters in its Parameters tab, right-click a state to make a transition, and give the transition a condition:
Two transition settings matter more for sprites than anything else. Has Exit Time, on by default, makes the transition wait until the current clip reaches a set point before switching. That's for 3D animations that must finish a step; for a sprite character it makes every input feel late, so turn it off. Transition Duration blends two clips over time, which means nothing for sprites (a sprite can't be half one frame and half another), so set it to 0. On a transition from Any State, also untick Can Transition To Self, or the Jump state restarts itself every frame while the keeper is in the air.
The script side passes values to the Animator. Look parameter names up once with Animator.StringToHash and use the hash, rather than passing strings every frame:
using UnityEngine;
namespace Handbook.T09
{
// Passes the runner's state to the Animator, and turns the keeper to face the way they run.
[RequireComponent(typeof(CliffRunner), typeof(Animator), typeof(SpriteRenderer))]
public class RunnerAnimation : MonoBehaviour
{
static readonly int SpeedId = Animator.StringToHash("Speed");
static readonly int GroundedId = Animator.StringToHash("Grounded");
[Tooltip("A child that should swap sides when the keeper turns, such as the lantern's light.")]
[SerializeField] Transform heldItem;
CliffRunner runner;
Rigidbody2D body;
Animator animator;
SpriteRenderer sprite;
void Awake()
{
runner = GetComponent<CliffRunner>();
body = GetComponent<Rigidbody2D>();
animator = GetComponent<Animator>();
sprite = GetComponent<SpriteRenderer>();
}
void Update()
{
animator.SetFloat(SpeedId, Mathf.Abs(body.linearVelocityX));
animator.SetBool(GroundedId, runner.Grounded);
// flipX mirrors the picture only: colliders and children stay where they are.
bool facingLeft = runner.FacingSign < 0f;
sprite.flipX = facingLeft;
if (heldItem != null)
{
Vector3 p = heldItem.localPosition;
p.x = Mathf.Abs(p.x) * runner.FacingSign;
heldItem.localPosition = p;
}
}
}
}To make a character face the other way, there are two ways. Flip X on the Sprite Renderer mirrors only the picture: colliders, child objects and their positions stay put, which is why the script also moves the lantern to the other side. A negative X scale on the Transform mirrors everything, children and colliders included, which is simpler for characters built from many parts, but it also flips any child with text or a health bar, and some physics shapes behave oddly when scaled negatively. Pick one per character and stick to it.
Animation Events call a method on the object at a set frame of a clip: a footstep sound when the foot lands, a hitbox that turns on during the swing frames. Add one with the event button on the Animation window's timeline. T19 · Character Animation: Animator, Blend Trees, Layers, Root Motion, Humanoid IK & Retargeting covers the Animator in depth: blend trees, layers and state machine behaviours.
For a prop with one looping animation, an Animator is more than you need. This plays a list of sprites in a loop:
using UnityEngine;
namespace Handbook.T09
{
// Plays a list of sprites in a loop, with no Animator. Fine for props with one looping
// animation (a crab's shuffle, a flickering torch); use an Animator once there are states.
[RequireComponent(typeof(SpriteRenderer))]
public class FlipbookSprite : MonoBehaviour
{
[SerializeField] Sprite[] frames;
[SerializeField] float framesPerSecond = 6f;
[SerializeField] bool randomStart = true; // so a row of crabs doesn't move in step
SpriteRenderer spriteRenderer;
float startTime;
void Awake()
{
spriteRenderer = GetComponent<SpriteRenderer>();
startTime = randomStart ? Random.value * 10f : 0f;
}
void Update()
{
if (frames == null || frames.Length == 0) return;
int index = (int)((Time.time + startTime) * framesPerSecond) % frames.Length;
spriteRenderer.sprite = frames[index];
}
}
}Bones: the 2D Animation package#
The 2D Animation package (installed with the 2D template) turns a sprite into a mesh that bends with a skeleton:
- Import the parts. Draw the character in layers (head, torso, upper arm, lower arm…) and save a
.psbfile; the PSD Importer imports each layer as a sprite and builds a prefab with them in place. The Aseprite Importer does the same for Aseprite files, and also turns Aseprite tags into animation clips, which makes it the quickest route for frame-by-frame pixel art too. - Rig it. In the Sprite Editor, switch to the Skinning Editor. Create Bone to draw the skeleton, Auto Geometry to mesh each part, Auto Weights to decide how much each bone moves each vertex, then fix the weights by hand where a joint bends badly.
- Animate. Each part gets a Sprite Skin component; you animate by rotating bones in the Animation window, as with any Transform.
- IK. An IK Manager 2D with Limb (two bones: arms, legs), CCD or FABRIK (chains: tails, tentacles) solvers lets you place a hand or foot and have the bones follow.
- Swap parts. A Sprite Library asset lists categories of sprites (Head: normal, hurt, happy), and a Sprite Resolver on a part picks one. Animate the resolver to change a face mid-animation, or swap the whole library for a different outfit with the same rig.
Bones give smooth motion and small files, at the cost of a "puppet" look if overdone and a slower start: rigging takes hours. Frames give total control over every pose, at the cost of drawing every one. Many 2D games mix them: frame-by-frame for the hero, bones for big bosses.
2D lights#
With the 2D Renderer, sprites using the Sprite-Lit-Default material are lit by Light 2D components. Unlit sprites (Sprite-Unlit-Default) ignore them, which is right for UI-like things in the world, glowing eyes and the moon. Light 2D is not the 3D Light component, and 3D lights do nothing to 2D-lit sprites.
| Light 2D type | Shape | Use |
|---|---|---|
| Global | Everything, evenly | The ambient level: bright for day, dim blue for night. One per blend style and sorting layer. |
| Spot | A circle, with Inner and Outer radius, narrowed to a cone by Inner / Outer Spot Angle | Lanterns, torches, headlights, a torch beam. In code it's Light2D.LightType.Point. |
| Freeform | A polygon you edit, with a soft Falloff edge | Light through a window, a lit doorway, a glowing pool |
| Sprite | The shape of a sprite you give it | God rays, patterned light, a stained-glass glow |
Create them from GameObject > Light (Spot Light 2D, Global Light 2D, Freeform Light 2D, Sprite Light 2D). The settings they share:
- Color and Intensity.
- Target Sorting Layers: which sorting layers this light lights. Leave the UI-like layers out.
- Blend Style: how the light combines with the sprites. The 2D Renderer asset defines four: Multiply (normal light), Additive (glow that brightens beyond the sprite's colours), and versions of each that a mask texture limits.
- Light Order and Overlap Operation (Additive or Alpha Blend): how overlapping lights of one blend style combine.
- Volumetric: an Intensity above 0 makes the light visible in the air itself, as a haze.
- Shadows: tick to have shadow casters block it, with Strength and Softness.
- Normal Maps: Quality (Disabled, Fast or Accurate) and Distance, for sprites with normal maps.
Normal maps make flat sprites look shaped: a light passing a normal-mapped rock lights its bumps from the right side. Draw or generate a normal map image for the sprite sheet, then in the Sprite Editor's Secondary Textures mode add it with the name _NormalMap. Then set the light's Normal Maps Quality to Fast or Accurate.
Shadows come from Shadow Caster 2D components. Its Casting Source chooses the shape: draw one with the Shape Editor, or take it from a component on the same object (a Collider 2D, Sprite Renderer, Sprite Shape or Sprite Skin), which follows the object's real shape with no extra work. For a tilemap, put the caster next to the Composite Collider 2D and choose it as the source: one caster for the whole level. Casting Option decides whether the caster's own sprite is shadowed too. A Composite Shadow Caster 2D on a parent merges its children's casters into one, so overlapping props don't shadow each other in stripes.
Lights are components like any other, so code can drive them. The type lives in URP's namespace, UnityEngine.Rendering.Universal:
using UnityEngine;
using UnityEngine.Rendering.Universal;
namespace Handbook.T09
{
// Makes a 2D light flicker like a flame: its brightness and reach wander with Perlin noise.
[RequireComponent(typeof(Light2D))]
public class LanternFlicker : MonoBehaviour
{
[SerializeField] float baseIntensity = 1.2f;
[SerializeField, Range(0f, 1f)] float flicker = 0.25f; // how far the brightness strays
[SerializeField] float speed = 4f;
Light2D lamp;
float baseRadius;
float seed;
void Awake()
{
lamp = GetComponent<Light2D>();
baseRadius = lamp.pointLightOuterRadius;
seed = Random.value * 100f; // so two lanterns don't flicker in step
}
void Update()
{
float n = Mathf.PerlinNoise1D(seed + Time.time * speed); // 0 to 1
lamp.intensity = baseIntensity * (1f + flicker * (n * 2f - 1f));
lamp.pointLightOuterRadius = baseRadius * (0.95f + 0.1f * n);
}
}
}Sprite Shape, parallax and particles#
Sprite Shape (2D SpriteShape package) builds terrain along a spline: a Sprite Shape Profile asset says which sprites to use for edges at different angles and what fills the inside, and a Sprite Shape Controller holds the spline you edit with handles in the Scene view (GameObject > 2D Object > Sprite Shape). It's the tool for rolling hills, cave walls with organic edges, and roads, where a tile grid would look blocky. Add an Edge Collider 2D or Polygon Collider 2D and it generates the collider from the spline.
Parallax makes a flat background feel deep: far layers move less than near ones when the camera moves. One short script does it. It must run after the camera has moved this frame, or the background trails a frame behind and jitters, hence DefaultExecutionOrder:
using UnityEngine;
namespace Handbook.T09
{
// Moves a background layer by part of the camera's movement, so it looks far away.
// 0 stays with the world (no parallax); 1 moves with the camera (infinitely far).
// Runs after the camera script so it uses this frame's camera position.
[DefaultExecutionOrder(100)]
public class Parallax2D : MonoBehaviour
{
[SerializeField, Range(0f, 1f)] float factor = 0.5f;
[SerializeField] bool vertical;
Transform cam;
Vector3 startPosition, cameraStart;
void Start()
{
cam = Camera.main.transform;
startPosition = transform.position;
cameraStart = cam.position;
}
void LateUpdate()
{
Vector3 moved = cam.position - cameraStart;
transform.position = startPosition + new Vector3(moved.x * factor, vertical ? moved.y * factor : 0f, 0f);
}
}
}The Particle System works in 2D as it is: give its renderer a sorting layer and order, use a sprite-based material, and for flip-book effects use Texture Sheet Animation in Sprites mode with frames from your sheet. T41 · The Particle System (Shuriken) in Depth covers particles in depth.
Guided build: Lantern Cliffs#
Below the lighthouse, the cliffs are riddled with caves, and the storm has scattered the lamp's oil flasks through them. In this small side-on platformer, the keeper climbs down through the caves at night with a lantern, collects five flasks, avoids the crabs and tries not to fall into the sea. It's a separate 2D project rather than part of the 3D island, because 2D lights need the 2D Renderer. You need no art: a script draws the pixel art. Along the way you'll import and slice sprites, paint a tilemap from a text map, merge its colliders, add one-way planks, build the platformer controller and its animations, follow it with a clamped camera, and light the caves.
The build uses PixelArtMaker (an editor script), CliffLayout, CliffRunner, PlankDrop, RunnerAnimation, CameraFollow2D, OilFlask, FlaskCounter, CrabPatrol, FlipbookSprite, KeeperHurt, TideReset, LanternFlicker and Parallax2D.
Step 1 · The project#
- In Unity Hub, create a project from the Universal 2D template, named
LanternCliffs. Put it under version control as in T01 · Setting Up: Unity 6, Hub, IDE, Packages & Version Control. - In the Project window, create
_ProjectwithArt,Tiles,Prefabs,Animation,ScriptsandScripts/Editorinside. RenameSampleScenetoCliffsand move it into_Project. - In Edit > Project Settings > Tags and Layers, add the Sorting Layers
Background,LevelandCharactersbelow Default, in that order. Name User Layers 6 to 9Ground,Planks,KeeperandHazards. - Select the Main Camera. Set Background to a night blue, such as (24, 32, 60). Add a Pixel Perfect Camera: Assets Pixels Per Unit 16, Reference Resolution 320 × 180, Crop Frame None, Grid Snapping Pixel Snapping. The camera's Size field goes grey: the component sets it now, to 5.625 for this resolution.
Step 2 · Drawing the sprites#
using System.IO;
using UnityEditor;
using UnityEngine;
namespace Handbook.T09.EditorTools
{
// Draws Lantern Cliffs' placeholder pixel art and imports it ready for slicing.
// Tools > Lantern Cliffs > Make Sprites writes three sheets of 16 x 16 cells:
// Tiles: rock, mossy rock, plank, cave wall
// Keeper: idle 1-2, run 1-4, jump
// Props: crab 1-2, oil flask
public static class PixelArtMaker
{
public const int Cell = 16;
const string Folder = "Assets/_Project/Art";
static readonly Color32 RockDark = new Color32(46, 44, 58, 255);
static readonly Color32 Rock = new Color32(74, 70, 86, 255);
static readonly Color32 RockLight = new Color32(104, 98, 112, 255);
static readonly Color32 Moss = new Color32(70, 120, 64, 255);
static readonly Color32 MossLight = new Color32(112, 160, 80, 255);
static readonly Color32 Wood = new Color32(150, 98, 54, 255);
static readonly Color32 WoodDark = new Color32(92, 58, 34, 255);
static readonly Color32 Wall = new Color32(34, 33, 46, 255);
static readonly Color32 WallLight = new Color32(44, 42, 58, 255);
static readonly Color32 Coat = new Color32(44, 70, 130, 255);
static readonly Color32 CoatDark = new Color32(28, 42, 82, 255);
static readonly Color32 Skin = new Color32(232, 184, 140, 255);
static readonly Color32 Cap = new Color32(196, 58, 40, 255);
static readonly Color32 Boot = new Color32(36, 30, 30, 255);
static readonly Color32 Brass = new Color32(244, 204, 84, 255);
static readonly Color32 Shell = new Color32(214, 84, 60, 255);
static readonly Color32 ShellDark = new Color32(150, 48, 40, 255);
static readonly Color32 Glass = new Color32(150, 210, 220, 255);
static readonly Color32 Oil = new Color32(244, 170, 40, 255);
static readonly Color32 Shine = new Color32(255, 255, 255, 255);
static readonly Color32 Ink = new Color32(20, 20, 24, 255);
[MenuItem("Tools/Lantern Cliffs/Make Sprites")]
static void MakeSprites()
{
Directory.CreateDirectory(Folder);
Save("Tiles", DrawTiles());
Save("Keeper", DrawKeeper());
Save("Props", DrawProps());
AssetDatabase.Refresh();
foreach (string sheet in new[] { "Tiles", "Keeper", "Props" })
ConfigureImport($"{Folder}/{sheet}.png");
Debug.Log($"Sprites written to {Folder}. Slice each sheet in the Sprite Editor: Grid By Cell Size, 16 x 16.");
}
static void Save(string name, PixelCanvas canvas)
{
var texture = new Texture2D(canvas.Width, canvas.Height, TextureFormat.RGBA32, false);
texture.SetPixels32(canvas.Pixels);
File.WriteAllBytes($"{Folder}/{name}.png", texture.EncodeToPNG());
Object.DestroyImmediate(texture);
}
// The import settings pixel art needs: one unit per cell, no smoothing, no compression.
static void ConfigureImport(string path)
{
var importer = (TextureImporter)AssetImporter.GetAtPath(path);
importer.textureType = TextureImporterType.Sprite;
importer.spriteImportMode = SpriteImportMode.Multiple;
importer.spritePixelsPerUnit = Cell;
importer.filterMode = FilterMode.Point;
importer.textureCompression = TextureImporterCompression.Uncompressed;
importer.mipmapEnabled = false;
importer.SaveAndReimport();
}
public static PixelCanvas DrawTiles()
{
var c = new PixelCanvas(Cell * 4, Cell);
for (int y = 0; y < Cell; y++)
for (int x = 0; x < Cell; x++)
{
int n = Noise(x / 2, y / 2);
Color32 rock = n < 50 ? RockDark : n > 205 ? RockLight : Rock;
c.Set(x, y, rock);
// Mossy rock: the top three rows are moss, with a ragged lower edge.
bool moss = y >= Cell - 3 || (y == Cell - 4 && Noise(x, 99) > 120);
c.Set(Cell + x, y, moss ? (Noise(x, y) > 150 ? MossLight : Moss) : rock);
c.Set(Cell * 3 + x, y, n > 180 ? WallLight : Wall);
}
// A plank four pixels thick along the top of its cell, for one-way platforms.
c.Fill(Cell * 2, Cell - 4, Cell, 4, Wood);
c.Fill(Cell * 2, Cell - 4, Cell, 1, WoodDark);
c.Set(Cell * 2 + 3, Cell - 2, WoodDark);
c.Set(Cell * 2 + 12, Cell - 2, WoodDark);
return c;
}
public static PixelCanvas DrawKeeper()
{
var c = new PixelCanvas(Cell * 7, Cell);
// For each frame: how far the back and front legs move sideways, and how far the body bobs.
int[,] legs = { { 0, 0 }, { 0, 0 }, { -1, 2 }, { 0, 0 }, { 2, -1 }, { 0, 0 }, { -1, 2 } };
int[] bob = { 0, -1, 0, 1, 0, 1, 1 };
for (int f = 0; f < 7; f++)
{
int ox = f * Cell, oy = bob[f];
bool jump = f == 6;
// Legs and boots. The jump frame tucks them up.
Leg(c, ox + 5 + legs[f, 0], jump ? 2 : 0, jump ? 2 : 3);
Leg(c, ox + 9 + legs[f, 1], jump ? 3 : 0, jump ? 2 : 3);
// Coat with a darker hem, and an arm holding a lantern out in front.
c.Fill(ox + 4, 3 + oy, 8, 6, Coat);
c.Fill(ox + 4, 3 + oy, 8, 1, CoatDark);
c.Fill(ox + 11, 5 + oy, 2, 1, CoatDark);
c.Fill(ox + 12, 3 + oy, 2, 2, Brass);
c.Set(ox + 12, 5 + oy, Ink);
// Head, eye, and a cap with a brim over the face.
c.Fill(ox + 5, 9 + oy, 6, 4, Skin);
c.Set(ox + 9, 11 + oy, Ink);
c.Fill(ox + 4, 13 + oy, 7, 2, Cap);
c.Fill(ox + 9, 13 + oy, 4, 1, Cap);
}
return c;
}
static void Leg(PixelCanvas c, int x, int y, int height)
{
c.Fill(x, y + 1, 2, height - 1, CoatDark);
c.Fill(x, y, 2, 1, Boot);
}
public static PixelCanvas DrawProps()
{
var c = new PixelCanvas(Cell * 3, Cell);
for (int f = 0; f < 2; f++)
{
int ox = f * Cell;
c.Fill(ox + 3, 2, 10, 4, Shell);
c.Fill(ox + 4, 6, 8, 1, Shell);
c.Fill(ox + 3, 2, 10, 1, ShellDark);
// Eyes on stalks.
c.Fill(ox + 5, 7, 1, 2, ShellDark);
c.Fill(ox + 10, 7, 1, 2, ShellDark);
c.Set(ox + 5, 9, Ink);
c.Set(ox + 10, 9, Ink);
// Claws, raised in the second frame.
c.Fill(ox + 1, 4 + f, 2, 3, ShellDark);
c.Fill(ox + 13, 4 + f, 2, 3, ShellDark);
// Legs that shuffle between frames.
for (int leg = 0; leg < 4; leg++)
c.Fill(ox + 3 + leg * 3 + f, 0, 1, 2, ShellDark);
}
int fx = Cell * 2;
c.Fill(fx + 5, 1, 6, 9, Glass);
c.Fill(fx + 6, 2, 4, 5, Oil);
c.Fill(fx + 7, 10, 2, 3, Glass);
c.Fill(fx + 7, 13, 2, 1, Wood);
c.Set(fx + 6, 8, Shine);
return c;
}
// A repeatable 0-255 value for each (x, y), so the art comes out the same every time.
static int Noise(int x, int y)
{
uint h = (uint)(x * 374761393 + y * 668265263);
h = (h ^ (h >> 13)) * 1274126177u;
return (int)(h >> 24);
}
}
// A tiny drawing surface. Row 0 is the bottom, as in a Texture2D.
public class PixelCanvas
{
public readonly int Width, Height;
public readonly Color32[] Pixels;
public PixelCanvas(int width, int height)
{
Width = width;
Height = height;
Pixels = new Color32[width * height]; // all transparent
}
public void Set(int x, int y, Color32 colour)
{
if (x >= 0 && x < Width && y >= 0 && y < Height)
Pixels[y * Width + x] = colour;
}
public void Fill(int x, int y, int width, int height, Color32 colour)
{
for (int j = y; j < y + height; j++)
for (int i = x; i < x + width; i++)
Set(i, j, colour);
}
}
}- Copy
PixelArtMakerinto_Project/Scripts/Editor. Because the folder is namedEditor, Unity compiles it only for the editor, and it's left out of builds. - Choose Tools > Lantern Cliffs > Make Sprites. Three images appear in
_Project/Art:Tiles(64 × 16),Keeper(112 × 16) andProps(48 × 16). - Select
Tilesand look at its import settings. The script set them: Sprite (2D and UI), Multiple, 16 Pixels Per Unit, Point filtering, no compression. Change Filter Mode to Bilinear and look at the preview to see why pixel art needs Point, then change it back. - Click Sprite Editor, then Slice: Type Grid By Cell Size, Pixel Size 16 × 16, Pivot Center. Click Slice, then Apply. Do the same for
KeeperandProps.
Expand each image in the Project window to see its sprites, numbered left to right:
| Sheet | Sprites |
|---|---|
Tiles | 0 rock, 1 mossy rock, 2 plank, 3 cave wall |
Keeper | 0–1 idle, 2–5 run, 6 jump |
Props | 0–1 crab, 2 oil flask |
The drawing code is ordinary C#: a canvas of Color32 pixels with row 0 at the bottom, filled with rectangles and a noise function that gives the rock its texture. Change the colours or the shapes and run the menu again; the sprites update in place, slicing and all.
Step 3 · Tiles and the palette#
- Open Window > 2D > Tile Palette, create a new palette named
Cliffsin_Project/Tiles, and drag theTilesimage into it. Save the four tile assets into_Project/Tiles. - Select the tile
Tiles_3(the cave wall) and set its Collider Type to None. The others keep Sprite, so the plank's collider is just its four-pixel board. - Create the level's tilemaps: GameObject > 2D Object > Tilemap > Rectangular. Rename the Grid
Cliffsand its tilemapGround. Right-clickCliffsand add two more rectangular tilemaps,PlanksandBackdrop. - Set the Tilemap Renderers' sorting:
Backdropon Background;Groundon Level, order 0;Plankson Level, order 1. Set the GameObject layers: Ground on Ground, Planks on Planks.
Try painting: pick the rock tile, set the Active Tilemap to Ground, and paint a few cells. Then erase them, because the next step paints the whole level.
Step 4 · Painting the cliffs from a map#
using UnityEngine;
using UnityEngine.Tilemaps;
namespace Handbook.T09
{
// Paints Lantern Cliffs from a text map, so the whole level can be rebuilt in one click:
// right-click the component's header and choose Paint. The first line is the top row.
// # rock (mossy where the cell above isn't rock)
// = plank in a cave - plank in the open
// : cave air, with the cave wall behind it
// . open sky
// K keeper start o oil flask c crab
public class CliffLayout : MonoBehaviour
{
[Header("Tilemaps")]
[SerializeField] Tilemap ground;
[SerializeField] Tilemap planks;
[SerializeField] Tilemap backdrop;
[Header("Tiles")]
[SerializeField] TileBase rock;
[SerializeField] TileBase mossyRock;
[SerializeField] TileBase plank;
[SerializeField] TileBase caveWall;
[Header("Things to place")]
[SerializeField] Transform keeper;
[SerializeField] GameObject flaskPrefab;
[SerializeField] GameObject crabPrefab;
[Tooltip("Placed flasks and crabs go under this, and are deleted on the next Paint.")]
[SerializeField] Transform placedParent;
[SerializeField, TextArea(14, 20)] string map =
"....................................\n" +
"..K.................................\n" +
"############...#############.....o..\n" +
"############:::#############...----.\n" +
"####:::::::::::::::::::::###........\n" +
"####::o:::::===::::::o:::###........\n" +
"####=====::::::::::=====:###........\n" +
"####:::::::::::::::::::::###........\n" +
"####::::::::====::::::::::::........\n" +
"####:::c::::::::::::c:::::::....o...\n" +
"############################..-----.\n" +
"##############::::::::::::::::......\n" +
"##############::o:::::c:::::::......\n" +
"##############################......";
[ContextMenu("Paint")]
void Paint()
{
if (placedParent == null)
{
Debug.LogWarning("Set Placed Parent first, so a second Paint can clear the first one's flasks and crabs.", this);
return;
}
string[] rows = map.Replace("\r", "").Trim('\n').Split('\n');
ground.ClearAllTiles();
planks.ClearAllTiles();
backdrop.ClearAllTiles();
for (int i = placedParent.childCount - 1; i >= 0; i--)
DestroyImmediate(placedParent.GetChild(i).gameObject);
for (int r = 0; r < rows.Length; r++)
{
int y = rows.Length - 1 - r;
for (int x = 0; x < rows[r].Length; x++)
{
var cell = new Vector3Int(x, y, 0);
char c = rows[r][x];
switch (c)
{
case '#': ground.SetTile(cell, CharAt(rows, r - 1, x) == '#' ? rock : mossyRock); break;
case '=':
case '-': planks.SetTile(cell, plank); break;
case 'K': if (keeper != null) keeper.position = ground.GetCellCenterWorld(cell); break;
case 'o': Place(flaskPrefab, cell); break;
case 'c': Place(crabPrefab, cell); break;
}
// Flasks and crabs get the cave wall behind them when they're beside cave air.
bool marker = c == 'o' || c == 'c';
if (IsCave(c) || (marker && (IsCave(CharAt(rows, r, x - 1)) || IsCave(CharAt(rows, r, x + 1)))))
backdrop.SetTile(cell, caveWall);
}
}
// Shrink each tilemap's bounds to the cells in use, for the camera's limits.
ground.CompressBounds();
planks.CompressBounds();
backdrop.CompressBounds();
}
static bool IsCave(char c) => c == ':' || c == '=';
static char CharAt(string[] rows, int r, int x) =>
r >= 0 && r < rows.Length && x >= 0 && x < rows[r].Length ? rows[r][x] : '.';
void Place(GameObject prefab, Vector3Int cell)
{
if (prefab == null) return;
Vector3 position = ground.GetCellCenterWorld(cell);
#if UNITY_EDITOR
// Keeps the link to the prefab, so later prefab edits reach every copy.
var placed = (GameObject)UnityEditor.PrefabUtility.InstantiatePrefab(prefab, placedParent);
placed.transform.position = position;
#else
Instantiate(prefab, position, Quaternion.identity, placedParent);
#endif
}
}
}- Add
CliffLayouttoCliffs. Drag in the three tilemaps and the four tiles: rockTiles_0, mossy rockTiles_1, plankTiles_2, cave wallTiles_3. - Create an empty child of
CliffsnamedPlaced, and drag it into Placed Parent. The keeper, flasks and crabs don't exist yet, so leave those fields empty. - Right-click the component's header and choose Paint.
The cliffs appear: a grassy top with a shaft down into a cave, planks inside, a gap in the right wall out to the cliff face, a ledge above the sea and a low grotto. The map is the text in the component's Map field, one character per cell, top row first, so you can edit the level as text and paint again. The mossy tops are automatic: a rock cell with no rock above it gets the moss sprite, the same job a Rule Tile does.
Paint replaces every tile on the three tilemaps, and it isn't undoable. Finish the level layout in the text before you decorate by hand with the palette, or keep decorations on a fourth tilemap that Paint doesn't touch.
Step 5 · Solid rock and one-way planks#
- Select
Ground. Add Tilemap Collider 2D, then Composite Collider 2D. Set the Rigidbody2D that appears to Static. On the Tilemap Collider 2D, set Composite Operation to Merge. - Do the same on
Planks, then add a Platform Effector 2D and tick Used By Effector on its Composite Collider 2D. Leave Use One Way on and Surface Arc at 180. - Turn on the collider outlines: in the Scene view, the Gizmos menu shows them, or tick Always Show Colliders under Gizmos in the Physics 2D settings.
The rock is now a few outlines instead of hundreds of boxes, and each plank is one thin strip. Press Play: nothing moves yet, but nothing falls either, which is what the Static body is for.
Step 6 · The keeper#
- Drag the sprite
Keeper_0into the Hierarchy. Name the new objectKeeper, set its Layer to Keeper and its Sprite Renderer's Sorting Layer to Characters. - Add a Rigidbody2D: Body Type Dynamic, Gravity Scale 3, Collision Detection Continuous, Interpolate Interpolate, and tick Freeze Rotation Z under Constraints.
- Add a Capsule Collider 2D: Size (0.5, 0.9), Offset (0, −0.05). That's 8 pixels wide, with its bottom exactly at the keeper's boots.
- Create a Physics Material 2D in
_Project/ArtnamedSlick, with Friction 0, and drag it into the capsule's Material. - Add
CliffRunnerand set Ground Layers to Ground and Planks. AddPlankDropand set Plank Layers to Planks. - Drag
KeeperintoCliffLayout's Keeper field and Paint again: the keeper moves to the start, on the cliff top.
Press Play. Run with A/D, the arrow keys or a stick, jump with Space or the south button. Tap Jump for a hop, hold it for the full 3.5 tiles. Run off the cliff top into the shaft: you land on the plank below it. Hold Down and press Jump to drop through. Notice the details from the controller section: you can still jump a moment after running off a plank, and a press just before landing jumps on landing. The keeper slides down walls instead of sticking to them, thanks to the Slick material. The camera doesn't follow yet, so the keeper can leave the screen. CliffRunner also raises a Jumped event at each jump, which T10 · Your First Complete Game Loop: States, Scenes, UI, Audio & Builds uses for a jump sound.
Step 7 · Animation#
- Select
Keeper, open Window > Animation > Animation, click Create, and saveIdle.animin_Project/Animation. Unity adds an Animator and a controller named after the object. - Drag
Keeper_0andKeeper_1onto the timeline. Turn on Show Sample Rate in the window's ⋮ menu and set Samples to 3: a slow breath. - From the clip drop-down, Create New Clip named
Run: framesKeeper_2toKeeper_5, Samples 10. ThenJump: justKeeper_6. - Open Window > Animation > Animator. Add a Float parameter
Speedand a BoolGrounded. Make the transitions in the state diagram above: Idle → Run when Speed > 0.1, Run → Idle when Speed < 0.1, Any State → Jump when Grounded is false, Jump → Idle when Grounded is true. On every transition, untick Has Exit Time and set the duration to 0. On Any State → Jump, untick Can Transition To Self. - Add
RunnerAnimationto the Keeper. Leave Held Item empty for now.
Press Play. The keeper breathes when standing, runs when moving, tucks their legs in the air, and turns to face the way they run. Try ticking Has Exit Time again on Idle → Run: there's a lag of up to a third of a second before the run starts, which is exactly what it does wrong for sprite characters.
Step 8 · The camera#
using UnityEngine;
using UnityEngine.Tilemaps;
namespace Handbook.T09
{
// Follows a Rigidbody2D, looks ahead in the direction it's moving,
// and never shows anything outside a tilemap's bounds.
[RequireComponent(typeof(Camera))]
public class CameraFollow2D : MonoBehaviour
{
[SerializeField] Rigidbody2D target;
[SerializeField] Tilemap limits;
[SerializeField] Vector2 offset = new Vector2(0f, 1f);
[SerializeField] float smoothTime = 0.15f;
[SerializeField] float lookAhead = 2f;
[SerializeField] float lookAheadSpeed = 4f; // units per second
Camera cam;
Vector3 velocity;
float ahead;
void Awake() => cam = GetComponent<Camera>();
void LateUpdate()
{
if (target == null) return;
// Slide the look-ahead towards the side the target is moving to.
float vx = target.linearVelocityX;
float wanted = Mathf.Abs(vx) > 0.5f ? Mathf.Sign(vx) * lookAhead : ahead;
ahead = Mathf.MoveTowards(ahead, wanted, lookAheadSpeed * Time.deltaTime);
Vector3 goal = (Vector3)(target.position + offset) + new Vector3(ahead, 0f, 0f);
goal.z = transform.position.z;
Vector3 position = Vector3.SmoothDamp(transform.position, goal, ref velocity, smoothTime);
transform.position = limits != null ? KeepInside(position) : position;
}
Vector3 KeepInside(Vector3 position)
{
// localBounds covers the painted cells (after CompressBounds), in the tilemap's own space.
Bounds local = limits.localBounds;
Vector3 min = limits.transform.TransformPoint(local.min);
Vector3 max = limits.transform.TransformPoint(local.max);
float halfHeight = cam.orthographicSize;
float halfWidth = halfHeight * cam.aspect;
position.x = Fit(position.x, min.x, max.x, halfWidth);
position.y = Fit(position.y, min.y, max.y, halfHeight);
return position;
}
// Clamp to the range, or centre on it when the view is bigger than the level.
static float Fit(float value, float min, float max, float halfView) =>
max - min > halfView * 2f ? Mathf.Clamp(value, min + halfView, max - halfView) : (min + max) * 0.5f;
}
}Add CameraFollow2D to the Main Camera. Drag the Keeper into Target and the Ground tilemap into Limits. Press Play. The camera follows with a little delay, shifts ahead of the keeper in the direction they run, and stops at the edges of the level: you never see past the left wall or above the sky. The limits come from localBounds, which covers the painted cells because CliffLayout called CompressBounds. It reads the camera's Size, which the Pixel Perfect Camera sets, to know how much of the level fits on screen.
The keeper moves smoothly under the camera because the Rigidbody2D interpolates. Set Interpolate to None for a moment and run: the keeper jitters against the background, because physics moves 50 times a second and your screen probably updates 60 or more.
Step 9 · Oil flasks#
using UnityEngine;
namespace Handbook.T09
{
// A trigger the keeper collects by touching it.
[RequireComponent(typeof(Collider2D))]
public class OilFlask : MonoBehaviour
{
void Reset() => GetComponent<Collider2D>().isTrigger = true;
void OnTriggerEnter2D(Collider2D other)
{
// Crabs have a Rigidbody2D too, but no FlaskCounter, so they pass by.
Rigidbody2D body = other.attachedRigidbody;
if (body == null || !body.TryGetComponent(out FlaskCounter counter)) return;
counter.Add();
Destroy(gameObject);
}
}
}using System;
using UnityEngine;
namespace Handbook.T09
{
// Counts the oil flasks the keeper has collected, out of all the flasks in the scene.
public class FlaskCounter : MonoBehaviour
{
public int Collected { get; private set; }
public int Total { get; private set; }
// Raised with (collected, total) at the start and after every pickup.
public event Action<int, int> Changed;
void Start()
{
Total = FindObjectsByType<OilFlask>(FindObjectsSortMode.None).Length;
Changed?.Invoke(Collected, Total);
}
public void Add()
{
Collected++;
Changed?.Invoke(Collected, Total);
Debug.Log(Collected < Total
? $"Oil flasks: {Collected} of {Total}"
: "All the oil is in. The lamp will burn all night.");
}
}
}- Drag
Props_2into the Hierarchy, name itOilFlask, Sorting Layer Characters. Add a Circle Collider 2D with Radius 0.35, thenOilFlask(itsResetticks Is Trigger). Drag it into_Project/Prefabsand delete it from the scene. - Add
FlaskCounterto the Keeper. - Drag the OilFlask prefab into
CliffLayout's Flask Prefab and Paint again. Five flasks appear underPlaced.
Collect them. The Console counts "Oil flasks: 1 of 5" and so on, and the fifth says the lamp will burn all night. Two flasks are outside: one on a plank beyond the cliff edge (jump from the top of the cliff, before going down the shaft) and one on the ledge above the sea. FlaskCounter raises a Changed event too, which T10 · Your First Complete Game Loop: States, Scenes, UI, Audio & Builds uses for an on-screen counter.
Step 10 · Crabs and the tide#
using UnityEngine;
namespace Handbook.T09
{
// Walks back and forth along a floor, turning at walls and at edges.
// The body is kinematic: it moves only where this script says, and nothing pushes it.
[RequireComponent(typeof(Rigidbody2D))]
public class CrabPatrol : MonoBehaviour
{
[SerializeField] float speed = 1.5f;
[SerializeField] LayerMask groundLayers; // must not include the crab's own layer
[SerializeField] float halfWidth = 0.4f;
[SerializeField] float floorProbe = 0.7f; // from the centre down past the floor
Rigidbody2D body;
float direction = 1f;
void Awake()
{
body = GetComponent<Rigidbody2D>();
body.bodyType = RigidbodyType2D.Kinematic;
body.interpolation = RigidbodyInterpolation2D.Interpolate;
}
void FixedUpdate()
{
Vector2 position = body.position;
Vector2 forward = new Vector2(direction, 0f);
Vector2 frontFoot = position + forward * halfWidth;
bool wallAhead = Physics2D.Raycast(position, forward, halfWidth + 0.1f, groundLayers);
bool floorAhead = Physics2D.Raycast(frontFoot, Vector2.down, floorProbe, groundLayers);
if (wallAhead || !floorAhead)
direction = -direction;
body.MovePosition(position + new Vector2(direction * speed * Time.fixedDeltaTime, 0f));
}
void OnDrawGizmosSelected()
{
Vector3 p = transform.position;
Vector3 front = p + new Vector3(direction * halfWidth, 0f, 0f);
Gizmos.color = Color.yellow;
Gizmos.DrawLine(p, p + new Vector3(direction * (halfWidth + 0.1f), 0f, 0f));
Gizmos.DrawLine(front, front + Vector3.down * floorProbe);
}
}
}- Drag
Props_0into the Hierarchy, name itCrab, Layer Hazards, Sorting Layer Characters. Add a Box Collider 2D: Size (0.75, 0.4), Offset (0, −0.3). AddCrabPatrol(it adds a Rigidbody2D and makes it Kinematic) with Ground Layers Ground and Planks. AddFlipbookSpritewith FramesProps_0andProps_1. - Make it a prefab, delete it from the scene, drag it into Crab Prefab and Paint again. Three crabs appear.
- Add
KeeperHurtto the Keeper and drag the Keeper's own Sprite Renderer into its Sprite field.
using System.Collections;
using UnityEngine;
namespace Handbook.T09
{
// When a crab touches the keeper: knock them back, take control away for a moment,
// and flash the sprite for as long as they can't be hurt again.
[RequireComponent(typeof(Rigidbody2D))]
public class KeeperHurt : MonoBehaviour
{
[SerializeField] Vector2 knockback = new Vector2(6f, 7f);
[SerializeField] float stunTime = 0.35f;
[SerializeField] float safeTime = 1.2f;
[SerializeField] SpriteRenderer sprite;
public event System.Action Hurt; // raised on every hit that gets through
Rigidbody2D body;
CliffRunner runner;
float safeUntil;
void Awake()
{
body = GetComponent<Rigidbody2D>();
runner = GetComponent<CliffRunner>();
}
void OnCollisionEnter2D(Collision2D collision)
{
if (Time.time < safeUntil || !collision.gameObject.TryGetComponent(out CrabPatrol _)) return;
// Away from the crab, and up.
float away = Mathf.Sign(body.position.x - collision.transform.position.x);
body.linearVelocity = new Vector2(away * knockback.x, knockback.y);
safeUntil = Time.time + safeTime;
StartCoroutine(Stun());
StartCoroutine(Flash());
Hurt?.Invoke();
}
IEnumerator Stun()
{
// With the runner off, nothing overwrites the knockback velocity.
if (runner != null) runner.enabled = false;
yield return new WaitForSeconds(stunTime);
if (runner != null) runner.enabled = true;
}
IEnumerator Flash()
{
while (Time.time < safeUntil)
{
sprite.enabled = !sprite.enabled;
yield return new WaitForSeconds(0.08f);
}
sprite.enabled = true;
}
}
}The crabs shuffle along the cave floors and turn back at walls and edges. Walk into one: the keeper is thrown back and up, can't steer for a moment, and flashes while they can't be hurt again. The crab, being kinematic, isn't pushed at all. KeeperHurt raises a Hurt event at each hit, which T10 · Your First Complete Game Loop: States, Scenes, UI, Audio & Builds turns into lives and a sound. Select a crab during Play to see its two probe rays as gizmos. The crabs' Ground Layers mustn't include Hazards: the probes start inside the crab's own collider, and with Queries Start In Colliders on, they would hit it.
using UnityEngine;
namespace Handbook.T09
{
// A trigger in the sea under the cliffs. A dynamic body that falls in
// is put back at the respawn point, standing still.
[RequireComponent(typeof(Collider2D))]
public class TideReset : MonoBehaviour
{
[SerializeField] Transform respawnPoint;
// Checkpoints move this.
public Transform RespawnPoint
{
get => respawnPoint;
set => respawnPoint = value;
}
void Reset() => GetComponent<Collider2D>().isTrigger = true;
void OnTriggerEnter2D(Collider2D other)
{
Rigidbody2D body = other.attachedRigidbody;
if (body == null || body.bodyType != RigidbodyType2D.Dynamic) return;
// Setting the body's position moves it at once, without sweeping through
// everything in between.
body.position = respawnPoint.position;
body.linearVelocity = Vector2.zero;
}
}
}- Create an empty named
Startat (2.5, 12.5), where the keeper starts. - Create an empty named
Tideat (18, −4). Add a Box Collider 2D with Size (60, 2), thenTideReset, and dragStartinto Respawn Point.
Jump off the ledge into the sea: the keeper falls out of the bottom of the view, touches the tide, and reappears at the start, with the camera sweeping back across the level. Flasks you've collected stay collected.
Step 11 · Night, and a lantern#
- Select Global Light 2D. Set Intensity to 0.15 and Color to a cold blue such as (140, 165, 255). The cliffs go dark.
- Right-click the Keeper, choose Light > Spot Light 2D, and name it
Lantern. Set its position to (0.3, −0.25, 0), over the lantern in the keeper's hand. Radius Inner 0.5, Outer 6; Color (255, 205, 130); Intensity 1.2. Tick Shadows, Strength 0.8. - Add
LanternFlickerto Lantern. Drag Lantern intoRunnerAnimation's Held Item, so the light swaps sides when the keeper turns. - Select
Groundand add a Shadow Caster 2D. Set Casting Source to the Composite Collider 2D. - Open the OilFlask prefab and add a child Spot Light 2D: Outer radius 1.5, Intensity 0.8, an amber colour, no shadows.
Press Play. The keeper's lantern lights a warm circle that flickers, the rock casts shadows on the cave wall behind it, and the flasks glow faintly, so you can spot them across a dark cave. Walk past the gap in the cave wall and the light spills out onto the cliff face. The crabs and the keeper are lit by the same light, because their sprites use Sprite-Lit-Default.
Step 12 · Moon and headland#
- GameObject > 2D Object > Sprites > Circle. Name it
Moon, position (8, 11), scale (3, 3, 1), Color pale yellow, Sorting Layer Background, Order −10, Material Sprite-Unlit-Default so the night lighting doesn't dim it. AddParallax2D, Factor 0.9. - GameObject > 2D Object > Sprites > Square. Name it
Headland, position (40, 2), scale (20, 5, 1), Color a dark blue-grey, Sorting Layer Background, Order −5. AddParallax2D, Factor 0.6.
Run along the cliff top. The moon barely moves, the headland drifts slowly, and the cliffs move at full speed: three depths from three numbers. The Backdrop tilemap doesn't need parallax because it's part of the cave.
Step 13 · Break it on purpose, then commit#
- Set the Global Light's Intensity to 0: every lit sprite outside the lantern's reach goes black. Swap the keeper's material to Sprite-Unlit-Default: they stay bright in the dark, like a UI element.
- On the Pixel Perfect Camera, set Grid Snapping to None and walk slowly: sprites sit between pixels, and the art shimmers. Try Upscale Render Texture and compare.
- On
Ground, set the Tilemap Collider 2D's Composite Operation to None: the keeper now runs over hundreds of boxes, and may catch on a seam. - Set the Platform Effector's Surface Arc to 360: the planks are solid from every side, so you can't jump up through them.
- On
CliffRunner, set Fall Gravity Multiplier to 1 and Coyote Time to 0: jumps feel floaty and missed edges feel unfair. Put them back. - Rename
OnTriggerEnter2DinOilFlasktoOnTriggerEnter: it compiles, and the flasks can no longer be collected. Rename it back.
Save the scene and commit. You now have a small but complete platformer level; T10 · Your First Complete Game Loop: States, Scenes, UI, Audio & Builds adds what turns a level into a game.
Going further#
This chapter covers what every 2D game needs. T54 · Advanced 2D: Rigged Characters, Sprite Shape, Isometric Worlds, 2D Shaders & the Pixel-Art Pipeline, in P5, goes much further once you've met animation, art and shaders: rigged characters built from layered artwork, Sprite Shape worlds, isometric and hexagonal levels in depth, 2D lighting and shaders, and a full pixel-art pipeline. T63 · Platformers, 2D & 3D: Advanced Controllers, Moving Platforms, Wall & Ledge Moves, Dashes & Ability Gating takes the platformer controller further, in 2D and 3D. The sections below are smaller extensions you can try now.
The low-level 2D physics API#
Unity 6.3 adds a second way to use 2D physics, in the UnityEngine.LowLevelPhysics2D namespace: PhysicsWorld, PhysicsBody, PhysicsShape and joints, created and stepped from code with no GameObjects or components. It's built on version 3 of Box2D, and it's aimed at things the component workflow handles badly: thousands of bodies, several independent worlds, physics in jobs. The components in this chapter are unchanged and remain the normal way to make a game. This handbook doesn't use the new API yet; check its documentation before relying on it.
Predicting with a second physics scene#
To show where a thrown object will land, including bounces, you can simulate ahead. Load a copy of the level's colliders into a separate physics scene (LocalPhysicsMode.Physics2D when creating or loading a scene), put a copy of the projectile in it, and call PhysicsScene2D.Simulate a few dozen times in one frame, recording positions. The main world never notices. It costs a simulation step per point, so keep the copy small.
Isometric and hexagonal games#
An Isometric or Isometric Z as Y tilemap draws diamond cells; the second turns each cell's Z into height, for cliffs and stairs. Isometric games sort by Y (and a little by Z): set the 2D Renderer's sort mode to Custom Axis with an axis such as (0, 1, −0.26), set Tilemap Renderers to Individual mode where characters walk among tall tiles, and put character pivots at their feet. Hexagonal tilemaps (point-top or flat-top) suit strategy maps; T23 · Grids, A* Pathfinding & Turn-Based Systems covers grids and pathfinding on them.
Keeping 2D fast#
How 2D studios work#
Pixel-art games are usually designed at one reference resolution with whole-number scaling, and artists draw to the grid from the start; mixing pixel densities (a 16-pixel character next to a 64-pixel prop scaled down) is the most obvious sign of an amateur 2D game. Animation is planned as a list of states and frame counts before anyone draws. Levels are blocked out with plain tiles, played, and only then decorated, the same grey-boxing T29 · Level Design: Grey-Boxing, ProBuilder, Flow & Worldbuilding describes for 3D. And "game feel" values such as coyote time, jump buffering and fall gravity are exposed in the Inspector, as in CliffRunner, so designers can tune them during play.
Common mistakes & debugging#
Nothing collides, or no messages arrive#
- 2D and 3D mixed. A
BoxCollideron one object and aBoxCollider2Don the other never interact. Check that every collider and body in the pair ends in 2D. - The message name has no 2D.
OnCollisionEnterandOnTriggerEnterare never called by 2D physics. It must beOnCollisionEnter2D(Collision2D)orOnTriggerEnter2D(Collider2D), with exactly those parameter types. - No body that makes contacts. At least one of the two needs a Dynamic Rigidbody2D, or a Kinematic one with Use Full Kinematic Contacts (see the contact diagram).
- The layers don't collide. Check the Layer Collision Matrix on the Physics 2D page, not the Physics page.
- Simulated is off on the Rigidbody2D, or the collider is disabled.
Sprites look wrong#
- Blurry or smeared pixel art: Filter Mode Point, Compression None, and a Pixel Perfect Camera. Check the Sprite Atlas's own settings too, if the sprite is packed.
- Thin lines between tiles: neighbouring pixels bleeding in. Point filtering, Pixel Snapping, and padding in the Sprite Atlas usually fix it.
- A sprite is invisible: it's behind something on a later sorting layer or a higher order; or it's behind the camera (the camera sits at Z −10, so a sprite at Z −20 isn't drawn); or its colour's alpha is 0.
- Everything is black: the scene has no Global Light 2D and the sprites use Sprite-Lit-Default. Add one, or use the unlit material.
- 2D lights do nothing: the URP asset isn't using the 2D Renderer, or the sprite's sorting layer isn't in the light's Target Sorting Layers.
Movement feels wrong#
- Floaty jumps: raise Gravity Scale and add fall gravity. Real gravity on one-unit characters looks like the Moon.
- The character sticks to walls: a Physics Material 2D with Friction 0 on its collider.
- Jitter while the camera follows: turn on Interpolate on the Rigidbody2D, and move the camera in
LateUpdate. - Jump sometimes doesn't work: the ground check sees triggers or the character itself, or its normal range excludes slopes. Log
Groundedand draw the check. - The whole level falls when you press Play: the Composite Collider 2D added a Dynamic Rigidbody2D. Set it to Static.
- Falling through thin platforms at speed: Collision Detection Continuous on the fast body.
Exercises#
Exercise solutions are in the sandbox under Assets/Handbook/T09/Exercises.
1. Bouncy kelp
Add patches of kelp that bounce the keeper high into the air when they land on it, using only a tilemap and a Physics Material 2D.
Solution
Add a fourth tilemap, Kelp, on the Ground layer, with a Tilemap Collider 2D merged by a Composite Collider 2D on a Static body. Paint it with the mossy rock tile, tinted green with the tilemap's Color. Create a Physics Material 2D with Bounciness 0.9 and Bounce Combine set to Maximum, so the keeper's Slick material (Bounciness 0) can't weaken it, and put it in the Composite Collider 2D's Material slot. The runner never sets the vertical speed on landing, so the bounce survives. Hold Jump or not: the jump cut only applies to jumps the runner started.
2. An updraft in the shaft
Fill the shaft from the cliff top with a column of rising air that slows the keeper's fall and, if they stay in it, lifts them back up. No code.
Solution
Create an empty over the shaft with a Box Collider 2D about 3 wide and 8 tall, ticked Is Trigger and Used By Effector, and an Area Effector 2D: Force Angle 90, Use Global Angle on, Force Magnitude 60, Linear Damping 2. The numbers: the keeper's weight is mass × 9.81 × gravity scale, so 29.4 rising and 53 falling (scale 3, times 1.8 when falling). A push of 60 beats both, so the keeper slows, stops and rises until they leave the column. With a magnitude of 40 the column only slows the fall, which is a gentler effect. The force ignores the runner, because the runner never sets the vertical speed except to jump.
3. Checkpoints
Add lantern posts that light up when the keeper first reaches them and become the place the tide sends them back to.
Solution
using UnityEngine;
using UnityEngine.Rendering.Universal;
namespace Handbook.T09
{
// A lantern post. When the keeper first touches it, it lights up
// and becomes the place the tide sends them back to.
[RequireComponent(typeof(Collider2D))]
public class Checkpoint2D : MonoBehaviour
{
[SerializeField] TideReset tide;
[SerializeField] Transform respawnPoint; // where to stand; this object if empty
[SerializeField] Light2D flame; // starts switched off
bool reached;
void Reset() => GetComponent<Collider2D>().isTrigger = true;
void Start()
{
if (flame != null) flame.enabled = false;
}
void OnTriggerEnter2D(Collider2D other)
{
if (reached || other.attachedRigidbody == null) return;
if (!other.attachedRigidbody.TryGetComponent(out CliffRunner _)) return;
reached = true;
tide.RespawnPoint = respawnPoint != null ? respawnPoint : transform;
if (flame != null) flame.enabled = true;
Debug.Log($"Checkpoint: {name}");
}
}
}Make a post from a thin Square sprite with a Box Collider 2D trigger and a child Spot Light 2D (the flame). Drag the Tide object into Tide. Put one on the cave floor and one on the outside ledge. TideReset exposes RespawnPoint as a property for exactly this. The checkpoint tests for a CliffRunner, so crabs can't claim it.
4. Crumbling planks
Make a plank that shakes for half a second after the keeper lands on it, falls away, and comes back three seconds later.
Solution
using System.Collections;
using UnityEngine;
namespace Handbook.T09
{
// A plank that shakes when stood on, falls, and comes back a few seconds later.
[RequireComponent(typeof(Rigidbody2D), typeof(Collider2D))]
public class CrumblePlank : MonoBehaviour
{
[SerializeField] float shakeTime = 0.5f;
[SerializeField] float respawnTime = 3f;
Rigidbody2D body;
Collider2D plankCollider;
Vector2 home;
bool crumbling;
void Awake()
{
body = GetComponent<Rigidbody2D>();
plankCollider = GetComponent<Collider2D>();
body.bodyType = RigidbodyType2D.Kinematic;
home = body.position;
}
void OnCollisionEnter2D(Collision2D collision)
{
// Only when something lands on top, not when it bumps the side or the underside.
bool fromAbove = collision.collider.bounds.min.y >= plankCollider.bounds.max.y - 0.05f;
if (!crumbling && fromAbove && collision.rigidbody != null)
StartCoroutine(Crumble());
}
IEnumerator Crumble()
{
crumbling = true;
for (float t = 0f; t < shakeTime; t += Time.fixedDeltaTime)
{
body.MovePosition(home + new Vector2(Random.Range(-0.03f, 0.03f), 0f));
yield return new WaitForFixedUpdate();
}
body.bodyType = RigidbodyType2D.Dynamic; // gravity takes over
yield return new WaitForSeconds(respawnTime);
body.bodyType = RigidbodyType2D.Kinematic;
body.linearVelocity = Vector2.zero;
body.angularVelocity = 0f;
body.position = home;
body.rotation = 0f;
crumbling = false;
}
}
}A separate object, not a tile: the plank sprite Tiles_2, a Box Collider 2D with Size (1, 0.25) and Offset (0, 0.375) to fit the board, a Rigidbody2D, and this script, on the Planks layer so the keeper's ground check sees it. Kinematic while it waits, it carries the keeper like any floor; switching bodyType to Dynamic hands it to gravity. The "from above" test compares the two colliders' bounds, so bumping the plank from below doesn't set it off.
5. Digging
Let the keeper dig out the rock in front of them with Attack, or below them with Down + Attack, so they can make their own way into the grotto.
Solution
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.Tilemaps;
namespace Handbook.T09
{
// Attack digs out the rock tile in front of the keeper. The Composite Collider 2D
// rebuilds its shape by itself, so the gap is walkable at once.
[RequireComponent(typeof(CliffRunner))]
public class TileDigger : MonoBehaviour
{
[SerializeField] Tilemap ground;
[SerializeField] TileBase rock;
[SerializeField] TileBase mossyRock;
[SerializeField] float reach = 0.8f;
[SerializeField] int bottomRow = 0; // never dig the floor of the level
CliffRunner runner;
InputAction attack;
void Awake()
{
runner = GetComponent<CliffRunner>();
attack = InputSystem.actions.FindAction("Player/Attack", throwIfNotFound: true);
}
void Update()
{
if (!attack.WasPressedThisFrame()) return;
// Down + Attack digs below; otherwise dig ahead.
Vector2 direction = runner.MoveInput.y < -0.5f ? Vector2.down : new Vector2(runner.FacingSign, 0f);
Vector3Int cell = ground.WorldToCell((Vector2)transform.position + direction * reach);
if (cell.y <= bottomRow || !ground.HasTile(cell)) return;
ground.SetTile(cell, null);
// The rock below is now open to the air, so give it a mossy top.
Vector3Int below = cell + Vector3Int.down;
if (ground.GetTile(below) == rock)
ground.SetTile(below, mossyRock);
}
}
}Add it to the Keeper with the Ground tilemap and the two rock tiles. WorldToCell finds the cell just ahead; SetTile(cell, null) clears it, and the Composite Collider 2D rebuilds the outline at once, as its Generation Type is Synchronous. The shadow caster follows the collider, so the light spills into the new hole. Re-mossing the rock below shows GetTile. To let the player dig only some rock, make a separate "soft rock" tile and test for it.
6. A Rule Tile instead of the script
Make a Rule Tile that does CliffLayout's moss rule by itself, then a Rule Override Tile for a darker rock that reuses the rules.
Solution
Assets > Create > 2D > Tiles > Rule Tile. Set its Default Sprite to Tiles_0 and Default Collider to Sprite. Add one rule: in its 3 × 3 grid, click the top-middle cell until it shows a red cross ("not this tile"), and set the rule's sprite to Tiles_1. Drag the Rule Tile into both the Rock and Mossy Rock fields of CliffLayout and Paint: the result is the same, but now hand-painting with the palette also gets moss right, and erasing a block above rock re-mosses it. For the dark variant, Assets > Create > 2D > Tiles > Rule Override Tile, set its Tile to your Rule Tile, and replace both sprites with tinted copies.
7. Wall slide and wall jump
Pressing into a wall in mid-air should slow the fall to a slide, and Jump should then kick the keeper up and away from the wall.
Solution
using System.Collections;
using UnityEngine;
using UnityEngine.InputSystem;
namespace Handbook.T09
{
// Adds wall sliding and wall jumping to CliffRunner. Pressing into a wall while falling
// slows the fall; Jump then kicks off the wall, up and away from it.
[RequireComponent(typeof(CliffRunner), typeof(Rigidbody2D))]
public class WallJump : MonoBehaviour
{
[SerializeField] LayerMask wallLayers;
[SerializeField] float wallCheck = 0.35f; // from the centre to just past the side
[SerializeField] float slideSpeed = 2f;
[SerializeField] Vector2 kick = new Vector2(7f, 13f);
[SerializeField] float controlLock = 0.15f; // seconds before steering works again
CliffRunner runner;
Rigidbody2D body;
InputAction jump;
int wallSide; // -1 wall on the left, 1 on the right, 0 none
bool jumpQueued;
void Awake()
{
runner = GetComponent<CliffRunner>();
body = GetComponent<Rigidbody2D>();
jump = InputSystem.actions.FindAction("Player/Jump", throwIfNotFound: true);
}
void Update()
{
if (jump.WasPressedThisFrame() && wallSide != 0 && !runner.Grounded)
jumpQueued = true;
}
void FixedUpdate()
{
wallSide = 0;
if (Physics2D.Raycast(body.position, Vector2.left, wallCheck, wallLayers)) wallSide = -1;
else if (Physics2D.Raycast(body.position, Vector2.right, wallCheck, wallLayers)) wallSide = 1;
bool pushingIntoWall = wallSide != 0 && Mathf.Sign(runner.MoveInput.x) == wallSide && Mathf.Abs(runner.MoveInput.x) > 0.1f;
if (pushingIntoWall && !runner.Grounded && body.linearVelocityY < -slideSpeed)
body.linearVelocityY = -slideSpeed;
if (jumpQueued)
{
jumpQueued = false;
if (wallSide != 0)
{
body.linearVelocity = new Vector2(-wallSide * kick.x, kick.y);
StartCoroutine(LockSteering());
}
}
}
// Without this, the runner steers straight back into the wall on the next step.
IEnumerator LockSteering()
{
runner.enabled = false;
yield return new WaitForSeconds(controlLock);
runner.enabled = true;
}
}
}Add it beside CliffRunner with Wall Layers set to Ground. Short raycasts from the keeper's centre find a wall on either side; the keeper's own layer isn't in the mask, so the rays don't hit the keeper. The slide caps the falling speed only while the stick points into the wall. The control lock matters: without it, the runner's next step pulls the horizontal speed back towards the stick direction, which still points into the wall, and the kick fizzles. Raise the shaft walls in the map to give yourself somewhere to practise.
8. Something in the dark
Add a ghost that drifts towards the keeper only while it's outside the lantern's light, stops and fades when the light reaches it, and can creep up behind rock that blocks the light.
Solution
using UnityEngine;
using UnityEngine.Rendering.Universal;
namespace Handbook.T09
{
// A ghost that drifts towards the keeper, but only while it's out of the lantern's light.
// In the light it stops and fades. Rock blocks the light, so it can creep up behind walls.
[RequireComponent(typeof(SpriteRenderer))]
public class ShyGhost : MonoBehaviour
{
[SerializeField] Transform keeper;
[SerializeField] Light2D lantern;
[SerializeField] LayerMask blocksLight;
[SerializeField] float speed = 1.2f;
[SerializeField, Range(0f, 1f)] float reachFraction = 0.8f; // the dim edge of the light doesn't count
SpriteRenderer sprite;
public bool InLight { get; private set; }
void Awake() => sprite = GetComponent<SpriteRenderer>();
void Update()
{
Vector2 lamp = lantern.transform.position;
Vector2 me = transform.position;
bool inReach = lantern.enabled && Vector2.Distance(me, lamp) < lantern.pointLightOuterRadius * reachFraction;
InLight = inReach && !Physics2D.Linecast(lamp, me, blocksLight);
Color colour = sprite.color;
colour.a = Mathf.MoveTowards(colour.a, InLight ? 0.2f : 0.85f, 2f * Time.deltaTime);
sprite.color = colour;
// No Rigidbody2D: a ghost goes through walls.
if (!InLight)
transform.position = Vector2.MoveTowards(me, keeper.position, speed * Time.deltaTime);
}
}
}Use a Circle sprite, pale and semi-transparent, with Sprite-Unlit-Default so the darkness doesn't hide it, on the Characters sorting layer. Drag in the Keeper, the Lantern, and set Blocks Light to Ground. "In the light" is two tests: within most of the light's outer radius, and a Linecast from the lantern that hits no rock. That's the same thing the shadow caster draws, done in code. To make it dangerous, give it a trigger collider and have the keeper react to it; KeeperHurt tests for CrabPatrol, so a shared Hazard component that both carry is the tidy way to let one script handle both.
Check yourself#
A sprite is 32 pixels tall and its texture's Pixels Per Unit is 16. How tall is it in the world?
- 0.5 units
- 2 units
- 16 units
- 32 units
Pixels Per Unit converts pixels to units: 32 ÷ 16 = 2.
Your pixel art looks soft and smeared in the Game view. Which settings fix it?
- Generate Mip Maps on, Max Size 8192
- Mesh Type Tight, Pivot Bottom
- Filter Mode Point and Compression None
- Sprite Mode Multiple
Bilinear filtering blends neighbouring pixels and compression smears them. Pixel art needs both off, plus a whole-number camera scale.
An orthographic camera has Size 5. How much of the world does it show from the bottom of the screen to the top?
- 10 units
- 5 units
- It depends on the window's width
- 5 units times the aspect ratio
Size is half the view's height. The width depends on the aspect ratio; the height doesn't.
A script on an object with a Rigidbody2D has an OnCollisionEnter(Collision c) method. It compiles, but never runs. Why?
- It must be public
- Collision messages need Is Trigger ticked
- It only runs in FixedUpdate
- 2D physics calls
OnCollisionEnter2D(Collision2D)
2D physics has its own messages with 2D in the name and different parameter types. Unity doesn't warn about the 3D name.
A patrolling enemy has a Kinematic Rigidbody2D. It walks through a static trigger zone, and OnTriggerEnter2D never fires. What's the fix?
- Tick Is Trigger on the enemy too
- Tick Use Full Kinematic Contacts on the enemy's Rigidbody2D
- Move the enemy with transform.position instead
- Give the zone a Kinematic Rigidbody2D
By default a Kinematic body makes contacts only with Dynamic bodies. Use Full Kinematic Contacts adds contacts with static and kinematic ones.
A crab's raycast from its own centre always reports a hit at distance 0. What's happening?
- Raycasts in 2D can't start at a body's position
- The ray is too long
- Queries Start In Colliders is on and the mask includes the crab's own layer
- The crab needs Continuous collision detection
With that setting on (the default), a ray starting inside a collider hits it first. Leave the crab's layer out of the mask, or turn the setting off.
You add a Composite Collider 2D to your Ground tilemap and press Play. The whole level falls out of the screen. Why?
- The Composite added a Rigidbody2D, which is Dynamic until you set it to Static
- Composite colliders don't work with tilemaps
- Composite Operation is set to Merge
- Gravity is too strong
A Composite Collider 2D needs a body and adds one, with the default Dynamic type. Set Body Type to Static.
Two knights, each built from several sprites, overlap, and one's sword arm shows through the other's body. What fixes it?
- Raising each sprite's Order in Layer by 1
- Putting the knights on different physics layers
- Setting the camera to perspective
- A Sorting Group on each knight's root object
A Sorting Group makes a character's sprites sort as one unit against everything else, and by their own orders inside it.
The run animation starts a fraction of a second after the player starts moving. Which setting is most likely to blame?
- The clip's Samples is too high
- Has Exit Time on the Idle → Run transition
- Interpolate on the Rigidbody2D
- The Animator's Culling Mode
Has Exit Time makes the transition wait for the current clip to reach a set point. Sprite characters want it off and a duration of 0.
A full jump reaches 4 tiles. If the player lets go of Jump at take-off and the script halves the upward speed, about how high does the hop go?
- 2 tiles
- 3 tiles
- 1 tile
- 0.5 tiles
Height grows with the square of the take-off speed (h = v² ÷ 2g), so half the speed gives a quarter of the height.
Cheat sheet#
Sprites
- Pixel art: PPU = tile size, Point, no compression
- Slice sheets: Grid By Cell Size
- 9-slice: Border + Draw Mode Sliced
- Atlas sprites shown together
- Lit material for 2D lights; Unlit to ignore them
Sorting and cameras
- Sorting Layer, then Order in Layer, then axis
- Top-down: Custom Axis (0, 1, 0), pivots at feet
- Multi-sprite characters: Sorting Group
- Ortho Size = half the view height
- Pixel art: Size = ref height ÷ (2 × PPU)
Tilemaps
- Grid → Tilemaps → Tiles; paint in Tile Palette
- Collider: Tilemap Collider 2D + Composite (Static)
- Composite Operation: Merge
WorldToCell,SetTile,HasTileCompressBoundsbefore using bounds
2D physics
- Everything ends in 2D; never mix with 3D
- Dynamic, Kinematic, Static via
bodyType linearVelocity,linearVelocityX- Kinematic contacts: Full Kinematic Contacts
- Interpolate what the camera follows
Queries
RaycastHit2Dis true when it hits- Rays from inside hit you: mask your layer out
- Reuse a
ContactFilter2Dand a List - Ground:
IsTouching, normals 45°–135° - One-way down:
IgnoreCollision
Platformer feel
- Gravity Scale 2–4, more when falling
- Jump speed = √(2 g h)
- Coyote 0.1 s, buffer 0.1 s
- Let go early: halve speed, quarter height
- Friction 0 on the character's collider
Animation
- Clip per state; Samples sets the frame rate
- Has Exit Time off, duration 0
Animator.StringToHashonce- flipX mirrors the picture only
- Bones: Skinning Editor + Sprite Skin
Lights
- Needs the 2D Renderer
- Global for ambient; Spot for lamps
- Shadow Caster 2D, source = Composite
- Normal maps: Secondary Texture
_NormalMap - Code:
Light2Din URP's namespace
Courses that cover this#
Glossary#
- Sprite
- A rectangle of a texture with a pivot and a size in world units, drawn by a Sprite Renderer.
- Pixels Per Unit
- How many texture pixels make one world unit; sets how big sprites are in the world.
- Sprite sheet
- One texture holding many sprites, cut apart (sliced) in the Sprite Editor.
- Pivot
- The point of a sprite that sits at its Transform's position, and that it flips and rotates around.
- 9-slicing
- Stretching a sprite's middle while keeping its corners and edges at their own size.
- Sprite Atlas
- An asset that packs many sprites into one texture so they can be drawn together.
- Sorting Layer
- A named drawing bucket; later layers in the list draw in front of earlier ones.
- Order in Layer
- A number that sorts renderers within a sorting layer; higher draws in front.
- Sorting Group
- A component that makes an object's child renderers sort together as one item.
- Orthographic camera
- A camera without perspective; its Size is half the height of the view in world units.
- Pixel Perfect Camera
- A URP component that keeps pixel art at a whole-number scale on any screen.
- Tilemap
- A grid of cells, each referencing a tile, drawn and collided as one object.
- Tile
- An asset that says what a tilemap cell shows and what collider shape it has.
- Tile Palette
- The editor window, and asset, holding the tiles you paint with.
- Rule Tile
- A tile that picks its sprite from its neighbours, using a list of 3 × 3 rules.
- Composite Collider 2D
- A collider that merges other colliders on the same body into one set of outlines or polygons.
- Body type
- A Rigidbody2D's role: Dynamic (moved by physics), Kinematic (moved by script) or Static (never moves).
- Full Kinematic Contacts
- A Rigidbody2D setting that lets a kinematic body make contacts with static and other kinematic bodies.
- Effector
- A 2D component that changes how colliders behave: one-way platforms, conveyors, wind, magnets, buoyancy.
- One-way platform
- A platform that is solid from above and passable from below, made with a Platform Effector 2D.
- ContactFilter2D
- A set of conditions (layers, triggers, depth, normal angle) used to filter 2D contacts and query results.
- Coyote time
- A short grace period after leaving an edge during which a jump still works.
- Jump buffering
- Remembering a jump press for a moment, so a press just before landing still jumps.
- Animator Controller
- An asset of animation states, parameters and transitions that an Animator plays.
- Has Exit Time
- A transition setting that waits for the current clip to reach a point before switching.
- Sprite Skin
- The 2D Animation component that deforms a sprite's mesh with bones.
- Light 2D
- URP's 2D light component: Global, Spot, Freeform or Sprite, working with the 2D Renderer.
- Normal map
- A texture storing surface directions, so lights can shade a flat sprite as if it had bumps.
- Shadow Caster 2D
- A component that blocks 2D lights, with a shape drawn by hand or taken from a collider or renderer.
- Parallax
- Moving background layers less than the foreground to suggest distance.