Getting Started with Unity
Set up Unity Hub, create your first mobile project, and understand how Unity's project structure maps to real game architecture.
Unity is a cross-platform game engine used by millions of indie and AAA studios. It runs on C# and supports 2D, 3D, AR, VR, and mobile targets โ all from one editor. Games like Pokรฉmon GO, Among Us, Hollow Knight, and Last Day on Earth were built in Unity.
Android + iOS
One codebase builds to both platforms with platform-specific modules.
Asset Store
Thousands of free and paid assets โ animations, shaders, UI kits.
C# Scripting
Full .NET runtime with modern C# features and excellent IDE support.
Free Tier
Personal license is free for projects earning under $200k/year.
- Download Unity Hub from unity.com/download โ this manages all your Unity versions.
- In Unity Hub โ Installs โ Install Editor โ choose Unity 6 (LTS recommended for mobile).
- When installing, check: Android Build Support, Android SDK & NDK Tools, iOS Build Support (Mac only), and OpenJDK.
- Install Visual Studio 2022 Community or JetBrains Rider as your IDE โ both integrate with Unity's debugger.
- In Unity Hub โ Licenses โ Get a free Personal license.
- Unity Hub โ New Project โ Select "3D (URP)" template โ URP (Universal Render Pipeline) is the standard for mobile.
- Name it
TheDeathRoad, choose a save location, click Create. - Once open: File โ Build Settings โ Android โ click Switch Platform. This tells Unity your target is mobile from day one.
- Go to Edit โ Project Settings โ Player โ set Company Name, Product Name, and Minimum API Level: Android 8.0 (API 26).
Resources/, StreamingAssets/, Plugins/, and Editor/ named exactly โ Unity treats them specially.Unity Core Concepts
Before touching the interface or writing a script, learn the five words every Unity tutorial assumes you already know โ Scene, GameObject, Component, Prefab, and the MonoBehaviour lifecycle. Everything else in this course builds on these.
A Scene is a single level or screen in your game โ a menu, a battle arena, a shop. It's saved as a .unity file. A game is usually many Scenes loaded one at a time (or streamed together for open worlds). Everything you place, move, or script lives inside whichever Scene is currently open.
A GameObject is the base building block of everything in a Scene โ a player, an enemy, a light, a UI button, even an empty organizational folder. On its own, a fresh GameObject does absolutely nothing. It has no shape, no image, no behavior. It's just a named container with a position in the world.
A Component is a piece of functionality you attach to a GameObject. This is the part people find confusing at first: Unity doesn't have a "Player" class or an "Enemy" class built in. Instead, you build up behavior by stacking components onto an empty GameObject:
Every one of those is a separate, swappable piece. Want the player to stop being affected by gravity? Remove the Rigidbody. Want an invisible trigger zone? Keep the Collider, remove the SpriteRenderer. This "stack components" approach is called composition, and it's the core idea behind how Unity works โ very different from traditional class inheritance.
When you write a C# script and drag it onto a GameObject, it becomes a component exactly like SpriteRenderer or Rigidbody2D. Every script you write in Unity inherits from a base class called MonoBehaviour, which is what lets Unity call your code automatically at the right moments.
You'll see these three method names in almost every script in this course. They're not interchangeable โ Unity calls each one at a different, specific moment:
| Method | Called When | Use It For |
|---|---|---|
Awake() | The instant the GameObject loads, before anything else runs | Grabbing references to your own components (GetComponent<Rigidbody2D>()) |
Start() | Once, right before the first frame โ after all Awakes have run | Setup that depends on other objects already existing |
Update() | Every single frame, continuously, for as long as the object exists | Input checks, movement, anything that needs to happen constantly |
using UnityEngine; public class LifecycleDemo : MonoBehaviour { void Awake() { Debug.Log("1. Awake โ I exist"); } void Start() { Debug.Log("2. Start โ the scene is ready"); } void Update() { Debug.Log("3. Update โ this repeats every frame"); } }
Awake() before that object has initialized its own fields is a classic source of null reference errors. If something depends on another GameObject being fully set up first, put it in Start(), not Awake().Once you've built a GameObject with the right components attached (say, a fully working enemy with a Rigidbody, Collider, sprite, and AI script), you can drag it from the Hierarchy into the Project window to save it as a Prefab. From then on, that Prefab is a template โ you can spawn as many copies as you want at runtime with Instantiate(), and editing the original Prefab updates every copy in every Scene that uses it.
The Unity Interface
Learn every panel of the Unity editor โ Scene, Game, Hierarchy, Inspector, Project, and Console โ and how they work together.
Scene View
Where you place and position GameObjects visually in the world.
Game View
What the player's camera sees. Hit Play to run your game here.
Hierarchy
A tree of every GameObject in the current scene, with parent-child relationships.
Inspector
Shows all Components on the selected GameObject and lets you tweak values.
Project
Your Assets folder โ drag files here to import them into your project.
Console
Debug.Log(), errors, and warnings appear here. Your best debugging friend.
Everything in a Unity scene is a GameObject โ an empty container. GameObjects do nothing by themselves. You attach Components to give them behavior.
| Shortcut | Action |
|---|---|
W | Move tool (translate) |
E | Rotate tool |
R | Scale tool |
Ctrl+P | Play / Stop |
Ctrl+D | Duplicate selected object |
F | Focus camera on selected object |
Ctrl+Shift+N | New empty GameObject |
Ctrl+Z | Undo (works in most editor actions) |
In Unity, scripts are MonoBehaviours โ C# classes that attach to GameObjects. Two key lifecycle methods:
using UnityEngine; public class HelloWorld : MonoBehaviour { // Called ONCE when the GameObject first appears in the scene void Start() { Debug.Log("Scene started!"); } // Called EVERY FRAME (~60 times/sec on mobile) void Update() { // Game logic goes here } }
- In Project panel โ right-click Scripts/ โ Create โ C# Script โ name it
HelloWorld. - Double-click to open in your IDE. Paste the code above.
- Drag the script from Project onto a GameObject in the Hierarchy. Hit Play. Check Console.
2D Foundations
Sprites, tilemaps, cameras, sorting layers, and the complete 2D pipeline โ everything you need to build a 2D mobile game world.
A Sprite is a 2D image displayed in your game. Unity handles the import, slicing, packing, and rendering.
- Drag a PNG into your
Assets/Art/Sprites/folder. - Select it in Project โ in Inspector set Texture Type: Sprite (2D and UI).
- Set Pixels Per Unit (PPU) โ typically 32, 64, or 100. Keep it consistent across all art. PPU determines how many pixels = 1 Unity unit (meter).
- For spritesheets: set Sprite Mode: Multiple โ open Sprite Editor โ Slice โ Grid By Cell Size โ enter your frame size.
- Drag the sprite into the Scene or onto a GameObject. Unity auto-adds a SpriteRenderer.
Unity's Tilemap system lets you paint levels like pixels on a grid โ perfect for top-down survival games.
- Hierarchy โ right-click โ 2D Object โ Tilemap โ Rectangular. This creates a Grid parent with a Tilemap child.
- Window โ 2D โ Tile Palette โ Create New Palette โ name it "World".
- Drag your tileset sprites into the Tile Palette. Now you can paint tiles onto the scene grid.
- Create multiple Tilemaps for layering: Ground, Decoration, Walls, Shadows โ each as a child of the same Grid.
- Add a TilemapCollider2D + CompositeCollider2D to the Walls tilemap for efficient collision.
Grid (GameObject) โโโ Tilemap: Ground Order: 0 โโโ Tilemap: Shadows Order: 1 โโโ Tilemap: Objects Order: 2 โโโ Tilemap: Walls Order: 3 + TilemapCollider2D
For a mobile top-down game, your camera needs to follow the player and fit different screen sizes.
using UnityEngine; public class CameraFollow : MonoBehaviour { public Transform target; // Drag player here public float smoothSpeed = 8f; public Vector3 offset = new Vector3(0, 0, -10); void LateUpdate() { Vector3 desired = target.position + offset; Vector3 smoothed = Vector3.Lerp(transform.position, desired, smoothSpeed * Time.deltaTime); transform.position = smoothed; } }
In 2D, Unity needs to know what draws in front of what. Sorting Layers give you explicit control.
- Edit โ Project Settings โ Tags and Layers โ Sorting Layers. Add: Background, Ground, Shadows, Characters, Projectiles, UI.
- On each SpriteRenderer and Tilemap Renderer, set the correct Sorting Layer.
- Use Order In Layer for fine-grained control within the same layer (higher = in front).
- For characters in a top-down game, dynamically set Order in Layer based on Y position: lower Y = in front.
void Update() { // Multiply by -100 so lower Y = higher sort order GetComponent<SpriteRenderer>().sortingOrder = Mathf.RoundToInt(-transform.position.y * 100); }
2D Physics & Colliders
Rigidbodies, colliders, triggers, layers, and the full physics pipeline โ make objects collide, bounce, and interact.
Add a Rigidbody2D component to any object that should be affected by physics (gravity, forces, velocity).
| Body Type | Use Case |
|---|---|
| Dynamic | Player, enemies, projectiles โ fully simulated |
| Kinematic | Moving platforms, doors โ controlled by script, not forces |
| Static | Walls, ground โ never moves, super performant |
public class PlayerMovement : MonoBehaviour { public float speed = 5f; private Rigidbody2D rb; private Vector2 moveInput; void Awake() => rb = GetComponent<Rigidbody2D>(); void Update() { // Keyboard (replaced by touch joystick on mobile) moveInput.x = Input.GetAxisRaw("Horizontal"); moveInput.y = Input.GetAxisRaw("Vertical"); moveInput.Normalize(); } void FixedUpdate() { // Always move physics in FixedUpdate rb.linearVelocity = moveInput * speed; } }
BoxCollider2D
Rectangular. Best for most characters, tiles, and pickups.
CircleCollider2D
Perfectly round. Great for projectiles and rolling objects.
CapsuleCollider2D
Pill shape. Ideal for humanoid characters.
PolygonCollider2D
Matches sprite shape. Most accurate, most expensive โ use sparingly.
// Called when physics bodies touch (Is Trigger = false) void OnCollisionEnter2D(Collision2D col) { if (col.gameObject.CompareTag("Enemy")) TakeDamage(10); } // Called when entering a trigger zone (Is Trigger = true) void OnTriggerEnter2D(Collider2D other) { if (other.gameObject.CompareTag("Pickup")) { CollectItem(other.gameObject); Destroy(other.gameObject); } } // Continuously while inside trigger void OnTriggerStay2D(Collider2D other) { } // When leaving trigger void OnTriggerExit2D(Collider2D other) { }
- Edit โ Project Settings โ Tags and Layers โ Layers. Add: Player, Enemy, Projectile, Ground, Pickup.
- Assign layers to GameObjects in the Inspector (top-right dropdown).
- Edit โ Project Settings โ Physics 2D โ Layer Collision Matrix. Uncheck pairs that shouldn't collide (e.g. Enemy-Enemy, Projectile-Projectile).
2D Animation
Animator Controller, Blend Trees, Animation Events, and smooth state machines โ bring your sprites to life.
- Select sliced sprite frames in Project โ drag them all into the Scene. Unity auto-creates an Animation clip and a basic Animator.
- Open Window โ Animation โ Animation to see the timeline. Adjust Sample Rate (12 fps for pixel art, 24 for smooth).
- Open Window โ Animation โ Animator to see the state machine. This controls which animation plays when.
- Add Parameters (float Speed, bool IsAttacking, trigger Hit) to drive transitions between states.
private Animator anim; private Rigidbody2D rb; void Awake() { anim = GetComponent<Animator>(); rb = GetComponent<Rigidbody2D>(); } void Update() { // Speed param drives Idle โ Walk blend anim.SetFloat("Speed", rb.linearVelocity.magnitude); // Trigger one-shot attack animation if (Input.GetKeyDown(KeyCode.Space)) anim.SetTrigger("Attack"); }
For top-down movement, use a 2D Blend Tree to blend idle/walk animations based on movement direction.
- In Animator, right-click โ Create State โ From New Blend Tree.
- Double-click the Blend Tree โ set Blend Type: 2D Simple Directional.
- Parameters:
MoveX(float) andMoveY(float). - Add Motion fields for each direction: walk_up (0,1), walk_down (0,-1), walk_right (1,0), walk_left (-1,0). Add diagonals for 8-way.
- In script:
anim.SetFloat("MoveX", moveInput.x)andanim.SetFloat("MoveY", moveInput.y).
3D Fundamentals
Meshes, materials, lighting, cameras, and the 3D pipeline โ the foundation for your Death Road characters and world objects.
Primitives
Cube, Sphere, Capsule, Cylinder โ great for prototyping before art is ready.
Mesh + MeshRenderer
Import .fbx/.obj from Blender. MeshRenderer applies materials to it.
Skinned Mesh
Characters with bones/armatures. Used with Animator for 3D animation.
Prefabs
Saved reusable GameObjects. Instantiate enemies, pickups, effects from prefabs.
Materials define how a surface looks โ color, texture, shininess, transparency.
- Project โ right-click โ Create โ Material. Name it
PlayerMat. - In Inspector, Shader dropdown โ Universal Render Pipeline/Lit for realistic, or URP/Unlit for flat (great for 2.5D characters).
- Drag your character texture into the Base Map slot.
- Drag the material onto your mesh in the Scene or Inspector.
| Mode | Use Case | FOV |
|---|---|---|
| Perspective | Standard 3D games โ depth visible | 60ยฐ |
| Orthographic | 2.5D, isometric โ no perspective distortion | Size-based |
| Isometric | Perspective rotated 45ยฐ/35ยฐ โ like Diablo | 40โ50ยฐ |
public class CharController3D : MonoBehaviour { public float speed = 4f; private Rigidbody rb; void Awake() => rb = GetComponent<Rigidbody>(); void FixedUpdate() { float h = Input.GetAxisRaw("Horizontal"); float v = Input.GetAxisRaw("Vertical"); Vector3 dir = new Vector3(h, 0, v).normalized; rb.MovePosition(rb.position + dir * speed * Time.fixedDeltaTime); } }
Lighting & Materials
URP lighting, baked vs realtime, normal maps, emissives, and mobile-optimized lighting setups for your game world.
Directional Light
The sun. Infinite range, affects everything. One per scene max.
Point Light
Like a lightbulb โ spherical falloff from a point. For torches, lamps.
Spot Light
Cone of light. Flashlights, car headlights, dramatic effect.
Area Light
Bake-only โ large soft light from a rectangular area. Windows, panels.
| Type | Mobile Cost | Dynamic Objects | Best For |
|---|---|---|---|
| Baked | Near zero at runtime | โ Static only | Environment, terrain |
| Mixed | Moderate | โ Dynamic shadows | Main directional light |
| Realtime | High | โ Fully dynamic | Flashlights, pickups |
| Map | Controls |
|---|---|
| Base Map (Albedo) | The base color/texture |
| Normal Map | Fake surface bumps without extra geometry |
| Metallic Map | How metal-like the surface is |
| Smoothness | How shiny/rough (rough=matte, smooth=mirror) |
| Emission Map | Self-illuminated โ glows without needing a light |
| Occlusion Map | Baked ambient shadow in crevices |
2.5D โ The Death Road Way
3D characters on a 2D world plane โ exactly how Last Day on Earth works. Depth sorting, isometric cameras, billboard sprites, and the full hybrid pipeline.
2.5D means characters and objects are 3D meshes rendered on a constrained plane โ players can't move into/out of the screen. The camera is locked at an angle. The result feels dimensional but plays like a top-down 2D game.
Last Day on Earth: Survival uses 3D characters with skeletal animation on an isometric fixed camera. The world is a flat plane. Characters have real 3D depth (you can see their back when facing away). It looks great, runs fast, and is much easier to animate than pure sprite sheets.
- Create a Camera. Set Projection: Perspective, Field of View: 50.
- Rotate the camera: X = 45ยฐ, Y = 45ยฐ for isometric. Or X = 60ยฐ, Y = 0ยฐ for a top-down tilted view like LDOE.
- Position camera above and behind the player โ experiment with Z = -10, Y = 8.
- Attach a camera follow script that locks to player XZ position (never Y โ no vertical movement).
public class IsoCamera : MonoBehaviour { public Transform target; public Vector3 offset = new Vector3(0, 10, -7); public float smooth = 6f; void LateUpdate() { Vector3 goal = target.position + offset; transform.position = Vector3.Lerp( transform.position, goal, smooth * Time.deltaTime); transform.LookAt(target); } }
- Import your character .fbx from Blender (or use a Unity asset). Set Rig: Humanoid for mecanim animations.
- Place the character at Y = 0 (ground plane). Set Rigidbody โ Freeze Rotation XYZ and Freeze Position Y.
- Add a CapsuleCollider for physics. Size it to match the character.
- Movement is always on the XZ plane (no Y movement unless jumping).
- Rotate the character to face movement direction using
Quaternion.LookRotation.
public class Player25D : MonoBehaviour { public float speed = 4f; public float rotSpeed = 15f; private Rigidbody rb; private Animator anim; void Awake() { rb = GetComponent<Rigidbody>(); anim = GetComponent<Animator>(); } void FixedUpdate() { float h = Input.GetAxisRaw("Horizontal"); float v = Input.GetAxisRaw("Vertical"); Vector3 move = new Vector3(h, 0, v).normalized; // Move on XZ plane only rb.MovePosition(rb.position + move * speed * Time.fixedDeltaTime); // Rotate to face movement direction if (move != Vector3.zero) { Quaternion targetRot = Quaternion.LookRotation(move, Vector3.up); rb.MoveRotation(Quaternion.Slerp( rb.rotation, targetRot, rotSpeed * Time.fixedDeltaTime)); } // Drive animations anim.SetFloat("Speed", move.magnitude); } }
In 2.5D, objects closer to the camera (lower Z in isometric) should draw in front. Unity handles this automatically with the renderer's depth buffer โ but for transparent objects (trees, UI) you need manual sorting.
- For opaque 3D objects: Unity's depth buffer handles sorting automatically. No extra work needed.
- For transparent sprites (foliage, shadows, UI markers): Use the Sorting Group component + set Order based on world Z position.
- For ground decals and shadows: use a flat quad with an Unlit transparent material, slightly above Y=0 to avoid Z-fighting.
using UnityEngine.Rendering; public class DepthSort : MonoBehaviour { private SortingGroup sg; void Awake() => sg = GetComponent<SortingGroup>(); void Update() { // Objects farther from camera (higher Z) get lower order sg.sortingOrder = 5000 - Mathf.RoundToInt(transform.position.z * 10); } }
In a survival game like Death Road, objects should become transparent when the player walks behind them.
public class OcclusionFader : MonoBehaviour { public float fadeAlpha = 0.3f; public float fadeSpeed = 8f; private Renderer rend; private float targetAlpha = 1f; void Awake() => rend = GetComponent<Renderer>(); // Call this from a trigger on the building public void SetFaded(bool faded) => targetAlpha = faded ? fadeAlpha : 1f; void Update() { Color c = rend.material.color; c.a = Mathf.Lerp(c.a, targetAlpha, fadeSpeed * Time.deltaTime); rend.material.color = c; } }
Build Your First 2.5D Scene
Using everything from Modules 01โ08:
- Set up an isometric camera (X=50, Y=45) above a flat plane
- Place a 3D capsule as your player with the Player25D script
- Add 3 cube "buildings" and attach OcclusionFader triggers
- Add a directional light at 45ยฐ and bake ambient lighting
- Test that the player walks behind buildings correctly
Mobile Input & Touch
Virtual joysticks, tap detection, swipe gestures, and Unity's Input System โ build controls that feel native on touchscreens.
- Package Manager โ search "Input System" โ Install. When prompted, switch to the New Input System.
- Create โ Input Actions asset. Add an Action Map "Player" with actions: Move (Vector2), Attack (Button), Interact (Button).
- For Move, add bindings: WASD keyboard + Left Stick gamepad + a virtual joystick touch binding.
- Generate C# class: check "Generate C# Class" in the asset Inspector โ Apply.
public class PlayerInput : MonoBehaviour { private PlayerControls controls; // generated class private Vector2 moveInput; void Awake() { controls = new PlayerControls(); controls.Player.Move.performed += ctx => moveInput = ctx.ReadValue<Vector2>(); controls.Player.Move.canceled += _ => moveInput = Vector2.zero; controls.Player.Attack.performed += _ => DoAttack(); } void OnEnable() => controls.Enable(); void OnDisable() => controls.Disable(); }
using UnityEngine; using UnityEngine.EventSystems; public class VirtualJoystick : MonoBehaviour, IPointerDownHandler, IDragHandler, IPointerUpHandler { public RectTransform handle; public float maxRadius = 80f; public static Vector2 Input; private Vector2 center; public void OnPointerDown(PointerEventData e) { center = e.position; } public void OnDrag(PointerEventData e) { Vector2 delta = e.position - center; Vector2 clamped = Vector2.ClampMagnitude(delta, maxRadius); handle.anchoredPosition = clamped; Input = clamped / maxRadius; } public void OnPointerUp(PointerEventData e) { handle.anchoredPosition = Vector2.zero; Input = Vector2.zero; } }
Then in your player script, replace Input.GetAxisRaw() with VirtualJoystick.Input.x / VirtualJoystick.Input.y.
void Update() { foreach (Touch touch in UnityEngine.Input.touches) { if (touch.phase == TouchPhase.Began) OnTapStart(touch.position); if (touch.phase == TouchPhase.Ended) { if (touch.deltaPosition.magnitude < 10f) OnTap(touch.position); // short tap else OnSwipe(touch.deltaPosition); // swipe gesture } } }
UI & HUD Systems
Canvas, health bars, inventory slots, damage numbers, menus โ build a complete mobile game UI that looks great on any screen size.
- Hierarchy โ UI โ Canvas. Set Render Mode: Screen Space โ Overlay.
- Add a Canvas Scaler: UI Scale Mode โ Scale With Screen Size. Reference: 1080 ร 1920 (portrait) or 1920 ร 1080 (landscape). Match: Width or Height.
- Add a Canvas Group to control alpha/interactability for menu transitions.
- Use Safe Area anchoring for notch/island devices: anchor UI to the safe area rect at runtime.
public class SafeAreaFitter : MonoBehaviour { void Awake() { RectTransform rt = GetComponent<RectTransform>(); Rect safe = Screen.safeArea; Vector2 anchorMin = safe.position / new Vector2(Screen.width, Screen.height); Vector2 anchorMax = (safe.position + safe.size) / new Vector2(Screen.width, Screen.height); rt.anchorMin = anchorMin; rt.anchorMax = anchorMax; } }
using UnityEngine.UI; public class HealthBar : MonoBehaviour { public Slider slider; public Image fill; public Color fullColor = Color.green; public Color emptyColor = Color.red; public void SetHealth(float current, float max) { float t = current / max; slider.value = t; fill.color = Color.Lerp(emptyColor, fullColor, t); } }
public class DamagePopup : MonoBehaviour { public TextMeshProUGUI label; private float moveY = 1.5f; private float lifetime = 1f; private float t; public void Init(int dmg) { label.text = dmg.ToString(); } void Update() { t += Time.deltaTime; transform.position += Vector3.up * moveY * Time.deltaTime; label.alpha = Mathf.Lerp(1f, 0f, t / lifetime); if (t >= lifetime) Destroy(gameObject); } // Static spawner โ call this from anywhere public static void Spawn(Vector3 pos, int dmg) { DamagePopup popup = Instantiate(Resources.Load<DamagePopup>("DamagePopup")); popup.transform.position = pos; popup.Init(dmg); } }
NavMesh & Agent Basics
Bake a walkable surface, drop a NavMeshAgent on an enemy, and get it chasing the player โ the foundation every AI behavior builds on.
- Install: Package Manager โ AI Navigation (for Unity 6) โ Install.
- Select all walkable ground objects โ Inspector โ Static dropdown โ check Navigation Static.
- Window โ AI โ Navigation โ Bake tab โ click Bake. A blue overlay shows the walkable area.
- Add a NavMeshAgent component to the enemy GameObject. Set Speed, Stopping Distance, and Angular Speed.
using UnityEngine.AI; public class ZombieAI : MonoBehaviour { public Transform player; public float detectionRange = 8f; public float attackRange = 1.5f; public float attackCooldown = 1.2f; private NavMeshAgent agent; private float lastAttack; enum State { Idle, Chase, Attack } private State state = State.Idle; void Awake() => agent = GetComponent<NavMeshAgent>(); void Update() { float dist = Vector3.Distance(transform.position, player.position); state = dist < attackRange ? State.Attack : dist < detectionRange ? State.Chase : State.Idle; switch (state) { case State.Chase: agent.SetDestination(player.position); break; case State.Attack: agent.ResetPath(); if (Time.time > lastAttack + attackCooldown) { DoAttack(); lastAttack = Time.time; } break; default: agent.ResetPath(); break; } } void DoAttack() => Debug.Log("Zombie attacks!"); }
Distance checks alone make enemies feel like they have x-ray vision. Real detection combines a trigger radius with a line-of-sight check so enemies only "see" what's actually in front of them and not blocked by a wall.
bool CanSeePlayer() { Vector3 dir = player.position - transform.position; float angle = Vector3.Angle(transform.forward, dir); if (angle > fieldOfView * 0.5f) return false; if (Physics.Raycast(transform.position + Vector3.up, dir.normalized, out RaycastHit hit, detectionRange, obstacleMask)) { return hit.collider.CompareTag("Player"); } return true; }
CanSeePlayer() every few frames (e.g. via InvokeRepeating), not every Update() โ raycasts against many enemies add up fast on mid-range Android GPUs.Enemy AI State Machines
Go beyond chase-and-attack โ build a full Idle โ Wander โ Chase โ Attack โ Flee state machine with a suspicion meter, the pattern Death Road's enemies actually use.
| Approach | Best For | Complexity |
|---|---|---|
| Enum State Machine | Simple enemies (zombie, guard) | Low |
| ScriptableObject States | Multiple enemy types sharing logic | Medium |
| Behavior Designer / NodeCanvas | Complex NPC with dialogue + combat | High |
Instead of snapping straight from Idle to Chase the instant a player is detected, a suspicion meter ramps up gradually โ the enemy notices something, gets more alert, then commits to a chase. It reads as far more "alive" than a hard trigger.
- While the player is visible, increase
suspicioneach frame. - While hidden, let it decay back toward zero.
- Cross a low threshold โ Wander toward last known position. Cross a high threshold โ Chase.
public class EnemyFSM : MonoBehaviour { enum State { Idle, Wander, Chase, Attack, Flee } private State state = State.Idle; public float suspicion = 0f; public float suspicionRise = 25f; public float suspicionDecay = 10f; public float chaseThreshold = 70f; public float wanderThreshold = 20f; public float lowHealthFlee = 0.2f; public Health health; void Update() { suspicion += (CanSeePlayer() ? suspicionRise : -suspicionDecay) * Time.deltaTime; suspicion = Mathf.Clamp(suspicion, 0f, 100f); if (health.Percent < lowHealthFlee) state = State.Flee; else if (suspicion >= chaseThreshold) state = State.Chase; else if (suspicion >= wanderThreshold) state = State.Wander; else state = State.Idle; RunState(); } void RunState() { switch (state) { case State.Idle: DoIdle(); break; case State.Wander: DoWander(); break; case State.Chase: DoChase(); break; case State.Attack: DoAttack(); break; case State.Flee: DoFlee(); break; } } }
Flee isn't just "reverse the chase vector." Run straight away from the player and enemies get stuck backing into corners. Sample a few points around the enemy, keep the one farthest from the player and reachable on the NavMesh.
void DoFlee() { Vector3 away = (transform.position - player.position).normalized; Vector3 fleeTarget = transform.position + away * 6f; if (NavMesh.SamplePosition(fleeTarget, out NavMeshHit hit, 6f, NavMesh.AllAreas)) agent.SetDestination(hit.position); }
NavMesh.SamplePosition check, fleeing enemies will try to path off the NavMesh entirely and get stuck vibrating against a wall. Always validate the flee point first.Draw Calls & Batching
Every object you draw costs the GPU a "draw call." On mid-range Android, that budget is small โ here's how to spend it wisely.
Each unique material + mesh combination the GPU renders is a separate draw call. Mobile GPUs choke well before desktop ones โ a scene that runs at 500 draw calls on PC can drop a budget Android phone to single-digit fps. The fix isn't fewer objects; it's fewer unique draws.
| Device Tier | Safe Draw Call Budget |
|---|---|
| Low-end Android (2โ3 GB RAM) | ~80โ150 draw calls |
| Mid-range Android | ~150โ300 draw calls |
| Flagship / modern iOS | ~300โ500 draw calls |
- Mark non-moving scenery (ground, walls, props) as Static in the Inspector โ enables Static Batching automatically.
- For moving objects sharing one material (bullets, small props), Unity applies Dynamic Batching automatically if the mesh is under ~300 vertices.
- Batching only merges objects that share the same material โ two identical meshes with different materials still cost two draw calls.
Combining multiple textures into one shared sheet (an atlas) lets many objects reference the same texture, which is required for batching to actually merge them. This matters most for UI and small props like pickups, particles, and decals.
- Group sprites that appear together often (UI icons, item pickups) into one atlas using Unity's Sprite Atlas asset.
- Window โ 2D โ Sprite Atlas โ Create New โ drag in your loose sprites.
- Enable Include in Build and Unity will pack and reference the atlas automatically at runtime.
The Profiler tells you how much time rendering costs; the Frame Debugger shows which individual draw calls make up a frame, in order.
- Window โ Analysis โ Frame Debugger โ Enable.
- Step through the list โ each entry is one draw call. Look for repeated materials that should have batched but didn't.
- If two identical materials appear as separate draw calls, check that both use the exact same material asset โ even a duplicated instance breaks batching.
Profiling & Memory
Target 60fps on mid-range Android devices โ the Profiler workflow, object pooling, and the full optimization cheat sheet.
Before optimizing anything, measure. Unity's Profiler shows exactly where your frame time is going.
- Window โ Analysis โ Profiler (Ctrl+7).
- Hit Play and watch CPU, GPU, Memory, and Rendering graphs.
- Click a spike in the timeline to see the call stack. The longest bar is your problem.
- On a real Android device: connect via USB โ Profiler โ Target: [your device].
Creating and destroying GameObjects at runtime (bullets, enemies, FX) causes garbage collection spikes. Pool them instead.
using System.Collections.Generic; public class ObjectPool<T> where T : MonoBehaviour { private Queue<T> pool = new Queue<T>(); private T prefab; public ObjectPool(T prefab, int preload) { this.prefab = prefab; for (int i = 0; i < preload; i++) CreateNew(); } public T Get() { T obj = pool.Count > 0 ? pool.Dequeue() : CreateNew(); obj.gameObject.SetActive(true); return obj; } public void Return(T obj) { obj.gameObject.SetActive(false); pool.Enqueue(obj); } private T CreateNew() { T obj = Object.Instantiate(prefab); obj.gameObject.SetActive(false); pool.Enqueue(obj); return obj; } }
Object pooling fixes Instantiate/Destroy spikes, but garbage collection also creeps in from allocations you don't notice โ LINQ queries, string concatenation, and new inside Update().
| Common GC Trap | Fix |
|---|---|
list.Where(x => ...) in Update | Use a plain for-loop instead of LINQ |
"Score: " + score every frame | Only update the string when score actually changes |
new Vector3(...) in a hot loop | Reuse a cached field where possible (structs still allocate on the stack, but repeated boxing/closures don't) |
| Closures/lambdas capturing variables in Update | Move the lambda outside the loop or cache it once |
Update() is worth hunting down before it becomes a stutter on low-end devices.| Problem | Fix |
|---|---|
| Too many draw calls | Enable GPU Instancing + Static Batching |
| Overdraw (transparent layers) | Reduce transparent objects, use URP Shader Graph alpha clip |
| Physics spikes | Reduce FixedUpdate rate to 0.02 โ 0.033, fewer colliders |
| Memory spikes (GC) | Object pooling, avoid LINQ in Update, pre-allocate lists |
| Texture memory | ASTC compression, Texture Atlases, Mip Maps |
| Shader cost | URP/Lit โ URP/Simple Lit โ URP/Unlit (cheapest) |
| Shadow cost | Bake shadows, reduce shadow distance, use blob shadows |
Build & Ship
Generate APK/AAB for Android, set up signing, configure IL2CPP, and submit to Google Play and the App Store.
- File โ Build Settings โ Android โ Switch Platform.
- Player Settings โ Other Settings โ set Scripting Backend: IL2CPP (faster, smaller, required for 64-bit).
- Target Architectures: check ARM64 (required by Google Play). ARM7 optional for older devices.
- Set Minify: Release for both Java and IL2CPP. This shrinks the APK.
- Build โ choose .aab (Android App Bundle) for Google Play, .apk for sideload/testing.
- Player Settings โ Publishing Settings โ Keystore Manager โ Create New.
- Fill in alias, password, organization. Save the .keystore file somewhere safe.
- Unity will sign every build with this key automatically.
- Build a Release .aab with IL2CPP + ARM64 + minify on.
- Google Play Console โ Create App โ upload the .aab to Internal Testing first.
- Required: Privacy Policy URL, Content Rating questionnaire, Target Audience & Content form.
- Add screenshots: phone (minimum 2), feature graphic (1024ร500).
- Move from Internal โ Closed โ Open testing โ Production over several days.
Ship a Death Road Prototype
Build and sideload a prototype APK containing:
- 2.5D isometric scene with a playable character
- At least 2 zombie NavMesh enemies
- Virtual joystick + attack button
- Health bar HUD
- Object pooled bullet/swing VFX
- Builds without errors in Release mode with IL2CPP + ARM64
Capstone โ Ship a Vertical Slice
Everything from this course, combined into one small playable loop: a 2.5D scene, a NavMesh enemy with a real state machine, pooled effects, a UI HUD, and a signed release build. This is the project that proves you can actually ship.
A vertical slice is a small, complete loop โ not a big level, not more content, just every system from this course touching each other in one playable minute. It's the difference between "I finished the modules" and "I can put pieces together into something that runs on a phone."
- Scene: One 2.5D area (Module 8) โ a small enclosed space with the isometric camera locked to the player.
- Player: The 2.5D character controller (Module 8) with virtual joystick input (Module 9) and an attack button.
- Enemy: One NavMesh-baked enemy (Module 11) running the full state machine โ Idle โ Wander โ Chase โ Attack โ Flee (Module 12) โ not just a chase script.
- Effects: Attack swing or bullet VFX drawn from an object pool (Module 14) โ no
Instantiate/Destroyin the play loop. - UI: A health bar HUD (Module 10) that updates when the player takes damage.
- Performance: Static-batch the environment (Module 13), confirm draw calls stay inside the mid-range budget (~150โ300) using the Frame Debugger.
- Build: A signed, Release-mode .apk with IL2CPP + ARM64 (Module 15) that installs and runs on a real Android device.
| System | Passes whenโฆ |
|---|---|
| Enemy AI | It idles, notices the player at a distance (not instantly), chases, attacks, and flees below a health threshold |
| Pooling | The Profiler shows no Instantiate/Destroy GC spikes during combat |
| Performance | Frame Debugger shows draw calls within your device tier's budget |
| Build | Installs from a signed .apk on a physical Android device without errors |
Ship the Slice
Combine every checkpoint above into a single scene and sideload it to a real device. This is your first shippable proof of concept โ the same shape of project you'd hand to a publisher or drop into a Play Store closed test.
- 2.5D isometric scene with a playable character
- At least 1 enemy running the full Idle/Wander/Chase/Attack/Flee state machine
- Virtual joystick + attack button
- Health bar HUD that reflects real damage
- Object-pooled attack/bullet VFX โ zero runtime Instantiate/Destroy
- Builds without errors in Release mode with IL2CPP + ARM64, signed and installable