🌾 Farm Life
Build a complete farm game — cultivate crops, feed chickens, and chop trees using C# scripting in Unity.
What You'll Build
In this tutorial you'll build Farm Life — a top-down farm game where a player character walks around a farm, plants and harvests crops, feeds hungry chickens, and chops down trees. It is designed to use every scripting concept from the Week 4 lecture in a natural, visible way.
By the end of this tutorial you will have written 6 scripts that communicate through a shared interface — the same pattern used in professional Unity projects.
The Three Farm Activities
| Activity | Mechanic | Key Scripting Concept |
|---|---|---|
| 🌱 Cultivate | Plant seeds → wait → harvest ready crops | Coroutine, Enum state machine, Material swap |
| 🐔 Feed Chickens | Pick up feed → walk to chicken → press E | Inventory, Animator Trigger, OnTriggerEnter |
| 🪓 Chop Trees | Hold E near tree → progress bar fills → tree falls | Time.deltaTime, UI Slider, Destroy() |
Week 4 Concepts in Action
| Lecture Concept | Where it appears in Farm Life |
|---|---|
Awake() | Every interactable caches its components here |
Start() | GameManager sets up UI; chickens start wandering |
Update() | Player movement, tree chop progress, timer countdown |
LateUpdate() | Camera follows player after all movement resolves |
| Coroutine | Crop growing (10 sec), chicken re-hunger (30 sec), tree fall |
[SerializeField] | Grow times, chop duration, scores — all Inspector-tunable |
GetComponent | Cached in Awake() on every script |
OnTriggerEnter/Stay | Detecting player near crops, chickens, trees |
interface | IInteractable — one interaction system for all objects |
This tutorial covers two lab sessions. Week 4: Asset setup, scene, PlayerFarmer, CropPatch. Week 5: Chicken, Tree, GameManager, polish and extensions.
Asset Store Setup
Before writing a single line of code, import the free assets below. These give you a character, a farm environment, and animals — so you can focus entirely on scripting rather than 3D modelling.
Import the character package first, then the environment, then animals. This prevents namespace conflicts and keeps your Project window clean.
How to Import Each Asset
-
1Open the Asset Store in Unity
Go to Window → Asset Store. This opens your browser. Sign in with your Unity ID if prompted.
-
2Search for the exact asset name
Search "Starter Assets Third Person". Click the result. Click "Add to My Assets" then "Open in Unity".
-
3Import via Package Manager
Unity opens the Package Manager window automatically. Click Import. Keep all files ticked. Wait for the import to finish completely before importing the next asset.
-
4Verify the import
In the Project window, you should see a new folder for the asset. Expand it — you should see Models, Prefabs, Animations, and Materials folders.
Asset Store free assets are licenced for use in Unity projects only. Do not redistribute the raw asset files outside of your built game.
Scene Setup
Work through this checklist top-to-bottom. By the end you should have a playable scene with a character walking around — before writing a single script.
1A — Install Cinemachine (Required)
The Starter Assets character camera relies on Cinemachine — a Unity package that must be installed before the prefab will work correctly.
If Cinemachine is missing, Unity will show "The type or namespace 'Cinemachine' could not be found" compile errors and the PlayerArmature prefab will appear broken with missing script warnings. Install Cinemachine first, then import Starter Assets.
1B — Open the Starter Assets Sample Scene
Rather than building the camera system from scratch — which involves several fragile manual steps — use the sample scene that ships with Starter Assets. It has the character, camera, and input system already correctly wired together. You then save it as your own scene and build the farm on top of it.
The Starter Assets camera system involves three components that must be configured together: a Main Camera with a Cinemachine Brain, a PlayerFollowCamera virtual camera, and the PlayerInput component on the character. Getting any one of these slightly wrong causes "No cameras rendering" or broken movement. The sample scene has all of this already done correctly.
-
1Find the sample scene in the Project window
Navigate to
Assets → StarterAssets → ThirdPersonController → Scenes. You should see a scene called Playground (or similar). Double-click it to open it. -
2Press Play to confirm it works
Press Play. The character should be visible in the Game view and you should be able to move with WASD and look with the mouse. This confirms the character, camera, and input are all working. Press Play again to stop.
-
3Save it as your FarmScene
Go to File → Save As. Navigate to your
Assets/Scenes/folder. Name the file FarmScene. Click Save. You now have your own copy — the original Playground scene is unchanged. -
4Delete the sample obstacles
In the Hierarchy, select and delete anything that is not the character, the camera, or the lighting. This typically means deleting a Obstacles or Geometry root GameObject that contains the sample platform/course. Leave PlayerArmature, PlayerFollowCamera, Main Camera, and Directional Light in place.
-
5Press Play again to confirm the clean scene still works
Press Play. The character should still move and the camera should still follow — the character will fall into a void, which is fine. This confirms deleting the obstacles did not break anything. Press Play to stop.
Some versions of the package use a different folder path. Try: Assets → StarterAssets → Scenes or search the Project window for Playground. If there is no sample scene at all, go to the Package Manager → Starter Assets → click Import into Project to bring in the sample content.
1C — Ground and Terrain
1D — Reposition the Character on the Ground
Render pipeline mismatch. The Starter Assets URP version needs a URP project, and the Built-in version needs a Built-in project. Check your project type in Edit → Project Settings → Graphics. If the Scriptable Render Pipeline Asset field is empty, you have a Built-in project — download the Built-in version of Starter Assets. If it has a URP asset assigned, download the URP version.
1E — Farm Activity Zones
1F — Tags and Layers
Unity requires tags to be registered before use. Add these now:
1G — UI Canvas
Your scene should now have: a walking character with camera, a farmyard plane, 5 soil patches, 3 chickens, 4 trees, a feed bag, and a UI canvas with score/timer/prompt/slider.
Script Architecture
Before writing any code, understand how the six scripts communicate. This is the most important concept in the tutorial.
The Key Design: IInteractable
All three farm objects (CropPatch, Chicken, Tree) implement the same interface. The player doesn't need to know what it's standing next to — it just calls Interact() and the correct behaviour happens. This is called polymorphism and it keeps PlayerFarmer.cs simple and clean.
Without an interface (messy)
- if tag == "CropPatch" → cropPatch.Plant()
- if tag == "Chicken" → chicken.Feed()
- if tag == "Tree" → tree.Chop()
- Adding a new object = editing PlayerFarmer
With an interface (clean)
- IInteractable target = GetNearbyObject()
- target.Interact(this) — done!
- The object handles its own logic
- Adding new objects = zero changes to PlayerFarmer
IInteractable.cs — The Interface
This is not a MonoBehaviour — it is a plain C# interface. It defines a contract: any object that can be interacted with by the player must provide these two methods. It is tiny but architecturally important.
In Project window → right-click → Create → C# Script → name it exactly IInteractable. Delete the generated content and replace it with the code below.
// IInteractable.cs // ───────────────────────────────────────────────────────────────────────────── // This is a C# interface — not a MonoBehaviour. // Any script that can be interacted with by the player must implement // this interface. The player calls Interact() without caring what type // of object it is talking to (CropPatch, Chicken, or Tree). // ───────────────────────────────────────────────────────────────────────────── public interface IInteractable { // Called when the player presses E while standing near this object. // The PlayerFarmer reference is passed in so the object can access // the player's inventory or position if needed. void Interact(PlayerFarmer player); // Returns the prompt text shown on screen when the player is nearby. // Example: "Press E to Plant", "Press E to Feed Chicken" string GetPromptText(); }
CropPatch, Chicken, and Tree all already inherit from MonoBehaviour. C# does not allow multiple inheritance of classes — but a class can implement multiple interfaces. An interface is the right tool here.
Inventory.cs
A simple component that lives on the Player and tracks everything they are carrying. Other scripts ask the player's inventory whether they have enough feed, or tell it to add harvested crops.
// Inventory.cs // ───────────────────────────────────────────────────────────────────────────── // Tracks everything the player is carrying: feed bags, harvested crops, // and chopped wood. Other scripts call the public methods to add or // spend items rather than accessing the counts directly. // ───────────────────────────────────────────────────────────────────────────── using UnityEngine; public class Inventory : MonoBehaviour { // ── Starting quantities (tunable in the Inspector) ────────────────── [SerializeField] private int startingFeedBags = 0; // ── Private counts — other scripts use the public methods below ───── private int feedBags; private int cropsHarvested; private int woodChopped; // ── Properties — read-only access for other scripts ───────────────── public int FeedBags => feedBags; public int CropsHarvested => cropsHarvested; public int WoodChopped => woodChopped; // ──────────────────────────────────────────────────────────────────── // Awake: initialise quantities from Inspector values // ──────────────────────────────────────────────────────────────────── void Awake() { feedBags = startingFeedBags; } // ── Feed Bag methods ───────────────────────────────────────────────── public void AddFeedBag() { feedBags++; Debug.Log($"Picked up feed bag. Total: {feedBags}"); } // Returns true if the player had a feed bag to spend, false if empty. public bool SpendFeedBag() { if (feedBags <= 0) { Debug.Log("No feed bags left!"); return false; } feedBags--; Debug.Log($"Used feed bag. Remaining: {feedBags}"); return true; } // ── Crop methods ───────────────────────────────────────────────────── public void AddCrop(int value) { cropsHarvested++; // Notify the GameManager to update score GameManager.Instance.AddScore(value); Debug.Log($"Harvested crop worth {value} points. Total crops: {cropsHarvested}"); } // ── Wood methods ───────────────────────────────────────────────────── public void AddWood(int value) { woodChopped++; GameManager.Instance.AddScore(value); Debug.Log($"Chopped wood worth {value} points. Total wood: {woodChopped}"); } }
PlayerFarmer.cs
The central hub. Handles movement input and detects when the player is near an interactable object. When the player presses E, it calls Interact() on whatever object they are standing next to.
// PlayerFarmer.cs // ───────────────────────────────────────────────────────────────────────────── // Attached to the PlayerArmature. Handles interaction input and // keeps track of which IInteractable the player is currently near. // Movement is handled by the Starter Assets controller — we only // add the farming interaction layer here. // ───────────────────────────────────────────────────────────────────────────── using UnityEngine; using TMPro; public class PlayerFarmer : MonoBehaviour { // ── Inspector References ───────────────────────────────────────────── // promptText is the single TMP_Text you created in the Canvas. // There is no separate "panel" — we show/hide the text object directly. [SerializeField] private TMP_Text promptText; // interactionRadius controls how close the player must be to trigger a prompt. // Tune this in the Inspector — 2.0 is a good starting value. [SerializeField] private float interactionRadius = 2.0f; // ── Cached Components ──────────────────────────────────────────────── private Inventory inventory; // ── State ──────────────────────────────────────────────────────────── private IInteractable nearbyInteractable; // Mobile chop hold state — read by Tree.cs each frame [HideInInspector] public bool isMobileChopping = false; // ── Property ───────────────────────────────────────────────────────── public Inventory Inventory => inventory; // ──────────────────────────────────────────────────────────────────── // Awake: cache the Inventory component on this same GameObject // ──────────────────────────────────────────────────────────────────── void Awake() { inventory = GetComponent<Inventory>(); if (inventory == null) Debug.LogError("PlayerFarmer: Inventory component missing from this GameObject!"); } // ──────────────────────────────────────────────────────────────────── // Start: hide the prompt text at game start // ──────────────────────────────────────────────────────────────────── void Start() { HidePrompt(); } // ──────────────────────────────────────────────────────────────────── // Update: every frame, use Physics.OverlapSphere to find the closest // interactable object within interactionRadius. // // Why OverlapSphere instead of OnTriggerEnter? // The Starter Assets PlayerArmature uses a CharacterController, which // has unreliable trigger behaviour. OverlapSphere is a direct physics // query that works correctly regardless of how the player is set up. // No special collider setup is needed on the player. // ──────────────────────────────────────────────────────────────────── void Update() { FindNearestInteractable(); if (nearbyInteractable != null) { ShowPrompt(nearbyInteractable.GetPromptText()); // Keyboard: press E once to interact if (Input.GetKeyDown(KeyCode.E)) nearbyInteractable.Interact(this); } else { HidePrompt(); } // FeedBag pickup: check separately each frame CheckFeedBagPickup(); } // ──────────────────────────────────────────────────────────────────── // FindNearestInteractable: scan all colliders within interactionRadius // and set nearbyInteractable to the closest IInteractable found. // ──────────────────────────────────────────────────────────────────── private void FindNearestInteractable() { // QueryTriggerInteraction.Collide means we detect both trigger // and non-trigger colliders — works with any Asset Store prefab Collider[] nearby = Physics.OverlapSphere( transform.position, interactionRadius, Physics.AllLayers, QueryTriggerInteraction.Collide ); IInteractable closest = null; float closestD = float.MaxValue; foreach (Collider col in nearby) { // Skip the player's own colliders if (col.transform.root == transform.root) continue; IInteractable interactable = col.GetComponentInParent<IInteractable>(); if (interactable == null) continue; float dist = Vector3.Distance(transform.position, col.transform.position); if (dist < closestD) { closestD = dist; closest = interactable; } } nearbyInteractable = closest; } // ──────────────────────────────────────────────────────────────────── // CheckFeedBagPickup: auto-collect any FeedBag the player walks over // ──────────────────────────────────────────────────────────────────── private void CheckFeedBagPickup() { Collider[] nearby = Physics.OverlapSphere(transform.position, 1.0f); foreach (Collider col in nearby) { if (col.CompareTag("FeedBag")) { inventory.AddFeedBag(); Destroy(col.gameObject); } } } // ──────────────────────────────────────────────────────────────────── // Mobile button methods — called by on-screen UI buttons // ──────────────────────────────────────────────────────────────────── public void MobileInteract() { if (nearbyInteractable != null) nearbyInteractable.Interact(this); } public void MobileChopBegin() { isMobileChopping = true; } public void MobileChopEnd() { isMobileChopping = false; } // ──────────────────────────────────────────────────────────────────── // Prompt helpers // ──────────────────────────────────────────────────────────────────── private void ShowPrompt(string message) { if (promptText == null) return; promptText.gameObject.SetActive(true); promptText.text = message; } private void HidePrompt() { if (promptText != null) promptText.gameObject.SetActive(false); } // Called by Tree.cs before it destroys itself public void ClearNearbyInteractable() { nearbyInteractable = null; HidePrompt(); } }
Prompt Text: drag the TMP_Text prompt object from your Canvas into this field. Interaction Radius: leave at 2.0 to start — increase it if the prompt feels unresponsive, decrease it if it triggers too early. No Sphere Collider is needed on the player.
CropPatch.cs
The star of Week 4. This script manages the full lifecycle of a crop from bare soil to a ready-to-harvest plant using an enum state machine and coroutines for the timed growing stages.
State Machine
brown soil
dark soil
yellow sprout
green glow
resets
// CropPatch.cs // ───────────────────────────────────────────────────────────────────────────── // Manages the lifecycle of a single crop patch. // Implements IInteractable so the player can plant and harvest // by pressing E when nearby. // ───────────────────────────────────────────────────────────────────────────── using System.Collections; using UnityEngine; public class CropPatch : MonoBehaviour, IInteractable { // ── State Machine ──────────────────────────────────────────────────── // An enum cleanly names each stage — much better than using magic // integers (0, 1, 2, 3) which are hard to read and maintain. public enum CropState { Empty, // bare soil, ready to plant Planted, // seed in ground, starting to grow Growing, // sprouting — yellow visual Ready // fully grown — green, can be harvested } // ── Inspector Settings ─────────────────────────────────────────────── [SerializeField] private float plantedDuration = 10f; // seconds until Growing [SerializeField] private float growingDuration = 8f; // seconds until Ready [SerializeField] private int cropValue = 10; // score awarded on harvest [SerializeField] private string cropName = "Wheat"; // ── Materials for each visual state ────────────────────────────────── // Drag these in from the Project window in the Inspector. [SerializeField] private Material matEmpty; // brown soil [SerializeField] private Material matPlanted; // dark, moist soil [SerializeField] private Material matGrowing; // yellow/green sprout [SerializeField] private Material matReady; // bright green, ready // ── Cached Components ──────────────────────────────────────────────── private Renderer patchRenderer; // the mesh renderer on this soil patch private Coroutine growCoroutine; // stored so we can stop it if needed // ── Current State ──────────────────────────────────────────────────── private CropState currentState = CropState.Empty; // ──────────────────────────────────────────────────────────────────── // Awake: cache the Renderer component. // Use GetComponentInChildren — Asset Store prefabs almost always put // the mesh (and its Renderer) on a child object, not the root. // GetComponent would miss it and return null every time. // ──────────────────────────────────────────────────────────────────── void Awake() { // Search this object AND all children for a Renderer patchRenderer = GetComponentInChildren<Renderer>(); if (patchRenderer == null) Debug.LogError($"CropPatch on {name}: No Renderer found on this object or any child!"); } // ──────────────────────────────────────────────────────────────────── // Start: ensure the patch starts in the Empty visual state // ──────────────────────────────────────────────────────────────────── void Start() { SetVisualState(CropState.Empty); } // ──────────────────────────────────────────────────────────────────── // IInteractable implementation // ──────────────────────────────────────────────────────────────────── public string GetPromptText() { // Return the right prompt depending on the current state return currentState switch { CropState.Empty => "Press E to Plant", CropState.Planted => "Growing... please wait", CropState.Growing => "Almost ready...", CropState.Ready => $"Press E to Harvest {cropName}", _ => "" }; } public void Interact(PlayerFarmer player) { switch (currentState) { case CropState.Empty: Plant(); // start the growing process break; case CropState.Ready: Harvest(player); // give crop to player, reset patch break; // Planted/Growing states: do nothing — just show the prompt } } // ──────────────────────────────────────────────────────────────────── // Plant: transition from Empty to Planted, start growing coroutine // ──────────────────────────────────────────────────────────────────── private void Plant() { currentState = CropState.Planted; SetVisualState(CropState.Planted); Debug.Log($"{name}: Planted. Growing in {plantedDuration} seconds."); // Start the coroutine and store the reference so we could stop it growCoroutine = StartCoroutine(GrowSequence()); } // ──────────────────────────────────────────────────────────────────── // GrowSequence: the coroutine that advances the crop through stages. // // yield return new WaitForSeconds(t) pauses HERE and resumes after // t seconds — Update() and other coroutines keep running normally. // ──────────────────────────────────────────────────────────────────── private IEnumerator GrowSequence() { // ── Stage 1: Planted → Growing ────────────────────────────────── yield return new WaitForSeconds(plantedDuration); currentState = CropState.Growing; SetVisualState(CropState.Growing); Debug.Log($"{name}: Now growing. Ready in {growingDuration} seconds."); // ── Stage 2: Growing → Ready ──────────────────────────────────── yield return new WaitForSeconds(growingDuration); currentState = CropState.Ready; SetVisualState(CropState.Ready); Debug.Log($"{name}: {cropName} is ready to harvest!"); } // ──────────────────────────────────────────────────────────────────── // Harvest: add crop to inventory, reset patch to Empty // ──────────────────────────────────────────────────────────────────── private void Harvest(PlayerFarmer player) { Debug.Log($"{name}: Harvested {cropName} for {cropValue} points!"); player.Inventory.AddCrop(cropValue); // Reset the patch back to Empty so it can be planted again currentState = CropState.Empty; SetVisualState(CropState.Empty); } // ──────────────────────────────────────────────────────────────────── // SetVisualState: swap the material to match the current state. // This provides instant, visible feedback without any animation. // ──────────────────────────────────────────────────────────────────── private void SetVisualState(CropState state) { if (patchRenderer == null) return; patchRenderer.material = state switch { CropState.Empty => matEmpty, CropState.Planted => matPlanted, CropState.Growing => matGrowing, CropState.Ready => matReady, _ => matEmpty }; } }
Project window → right-click → Create → Material. Name them Mat_SoilEmpty, Mat_SoilPlanted, Mat_Growing, Mat_Ready. Set their Albedo colours to brown, dark-brown, yellow, and bright-green respectively. Drag them into the CropPatch Inspector fields.
Chicken.cs
Chickens wander randomly when hungry and stand still after being fed. Feeding requires the player to have a feed bag in their inventory. The chicken re-hungers after 30 seconds via a coroutine.
State Machine
wandering
idle / eating
wanders again
// Chicken.cs // ───────────────────────────────────────────────────────────────────────────── // A hungry chicken wanders randomly. The player feeds it by pressing E // while holding a feed bag. After 30 seconds the chicken gets hungry again. // ───────────────────────────────────────────────────────────────────────────── using System.Collections; using UnityEngine; public class Chicken : MonoBehaviour, IInteractable { // ── Inspector Settings ─────────────────────────────────────────────── [SerializeField] private float hungerDuration = 30f; // seconds before hungry again [SerializeField] private float wanderRadius = 3f; // how far the chicken strays [SerializeField] private float wanderInterval = 3f; // seconds between wander moves [SerializeField] private float wanderSpeed = 1.5f;// movement speed while wandering [SerializeField] private int feedScore = 15; // score for feeding this chicken // ── Cached Components ──────────────────────────────────────────────── private Animator animator; // ── State ──────────────────────────────────────────────────────────── private bool isHungry = true; private Vector3 homePosition; // where the chicken started private Vector3 wanderTarget; // current wander destination // ──────────────────────────────────────────────────────────────────── // Awake: cache the Animator component // ──────────────────────────────────────────────────────────────────── void Awake() { animator = GetComponent<Animator>(); homePosition = transform.position; wanderTarget = homePosition; } // ──────────────────────────────────────────────────────────────────── // Start: begin the wander coroutine immediately // ──────────────────────────────────────────────────────────────────── void Start() { StartCoroutine(WanderRoutine()); } // ──────────────────────────────────────────────────────────────────── // Update: move toward the current wander target each frame // ──────────────────────────────────────────────────────────────────── void Update() { if (!isHungry) return; // fed chickens stand still float distToTarget = Vector3.Distance(transform.position, wanderTarget); if (distToTarget > 0.1f) { // Move toward wander target using Time.deltaTime for frame-rate independence transform.position = Vector3.MoveTowards( transform.position, wanderTarget, wanderSpeed * Time.deltaTime ); // Face the direction of movement Vector3 dir = (wanderTarget - transform.position).normalized; if (dir != Vector3.zero) transform.rotation = Quaternion.LookRotation(dir); } } // ──────────────────────────────────────────────────────────────────── // WanderRoutine: every wanderInterval seconds, pick a new random // destination within wanderRadius of the home position. // ──────────────────────────────────────────────────────────────────── private IEnumerator WanderRoutine() { while (true) // loop forever — chicken always wanders when hungry { yield return new WaitForSeconds(wanderInterval); if (isHungry) { // Pick a random point within the wander radius Vector2 randomCircle = Random.insideUnitCircle * wanderRadius; wanderTarget = homePosition + new Vector3(randomCircle.x, 0, randomCircle.y); } } } // ──────────────────────────────────────────────────────────────────── // IInteractable implementation // ──────────────────────────────────────────────────────────────────── public string GetPromptText() { return isHungry ? "Press E to Feed Chicken (needs feed bag)" : "Chicken is happy!"; } public void Interact(PlayerFarmer player) { if (!isHungry) { Debug.Log("This chicken is already fed."); return; } // Attempt to spend a feed bag from the player's inventory bool hasFeed = player.Inventory.SpendFeedBag(); if (!hasFeed) { Debug.Log("You need a feed bag to feed the chicken. Find one on the farm!"); return; } // Feed successful! Feed(player); } // ──────────────────────────────────────────────────────────────────── // Feed: mark as fed, play animation, start the re-hunger timer // ──────────────────────────────────────────────────────────────────── private void Feed(PlayerFarmer player) { isHungry = false; // Trigger the Eat animation on the Animator Controller // The string "Eat" must match the Trigger parameter name exactly. if (animator != null) animator.SetTrigger("Eat"); // Award the player score directly via GameManager GameManager.Instance.AddScore(feedScore); Debug.Log($"Chicken fed! Will get hungry again in {hungerDuration} seconds."); // Start the countdown to hunger again StartCoroutine(HungerTimer()); } // ──────────────────────────────────────────────────────────────────── // HungerTimer: after hungerDuration seconds, the chicken is hungry again // ──────────────────────────────────────────────────────────────────── private IEnumerator HungerTimer() { yield return new WaitForSeconds(hungerDuration); isHungry = true; Debug.Log($"{name} is hungry again!"); } }
Create an Animator Controller for the chicken. Add two states: Idle (default) and Eat. Add a Trigger parameter named Eat. Make a transition from Idle → Eat with the condition Eat triggered, and a transition back from Eat → Idle with Has Exit Time = true (so the eating animation plays fully before returning to Idle).
Tree.cs
The tree uses a hold-to-chop mechanic — the player holds E and a progress bar fills up. This introduces Time.deltaTime in a very tangible way, and drives a UI Slider directly from a 3D script.
// Tree.cs // ───────────────────────────────────────────────────────────────────────────── // The player holds E to chop a tree. A UI Slider fills as chopping progresses. // When the progress reaches 1.0, the tree falls and is destroyed. // // Key concept: Time.deltaTime makes chopping speed frame-rate independent. // Without it, the chop speed would vary wildly between fast and slow machines. // ───────────────────────────────────────────────────────────────────────────── using System.Collections; using UnityEngine; using UnityEngine.UI; public class Tree : MonoBehaviour, IInteractable { // ── Inspector Settings ─────────────────────────────────────────────── [SerializeField] private float chopDuration = 4f; // seconds to fully chop [SerializeField] private int woodValue = 20; // score for chopping this tree [SerializeField] private float fallDuration = 1.2f;// seconds the tree takes to "fall" [SerializeField] private Slider chopProgressBar; // the UI Slider — drag in from Canvas // ── State ──────────────────────────────────────────────────────────── private float chopProgress = 0f; // 0.0 = untouched, 1.0 = fully chopped private bool isBeingChopped = false; // is the player currently holding E? private bool isFalling = false; // prevent double-chop once falling private PlayerFarmer currentPlayer; // the player that started chopping // ──────────────────────────────────────────────────────────────────── // Start: initialise the progress bar (it should be hidden at game start) // ──────────────────────────────────────────────────────────────────── void Start() { if (chopProgressBar != null) { chopProgressBar.value = 0f; chopProgressBar.gameObject.SetActive(false); } } // ──────────────────────────────────────────────────────────────────── // Update: advance chop progress while E is held (keyboard) OR while // the mobile chop button is held (isMobileChopping on PlayerFarmer). // // chopProgress += Time.deltaTime / chopDuration // → Time.deltaTime is the time since the last frame (e.g. 0.016s at 60fps) // → Dividing by chopDuration normalises it: 1 second of holding = 1/chopDuration // → After chopDuration seconds of holding, chopProgress reaches 1.0 // ──────────────────────────────────────────────────────────────────── void Update() { if (isFalling) return; // already done — stop processing // Accept input from keyboard (PC) OR mobile button flag bool chopInputHeld = Input.GetKey(KeyCode.E) || (currentPlayer != null && currentPlayer.isMobileChopping); if (isBeingChopped) { if (chopInputHeld) { // Advance progress (frame-rate independent) chopProgress += Time.deltaTime / chopDuration; chopProgress = Mathf.Clamp01(chopProgress); // keep between 0 and 1 // Drive the UI Slider if (chopProgressBar != null) chopProgressBar.value = chopProgress; // Trigger the fall when fully chopped if (chopProgress >= 1f) StartCoroutine(FallAndDestroy()); } else { // Player released — pause chopping but keep progress isBeingChopped = false; if (chopProgressBar != null) chopProgressBar.gameObject.SetActive(false); } } } // ──────────────────────────────────────────────────────────────────── // IInteractable implementation // ──────────────────────────────────────────────────────────────────── public string GetPromptText() { if (isFalling) return "Timber!"; if (chopProgress > 0f) return $"Hold E to continue chopping ({(int)(chopProgress * 100)}%)"; return "Hold E to Chop Tree"; } public void Interact(PlayerFarmer player) { if (isFalling) return; // Pressing E starts (or resumes) chopping // Update() will then check GetKey(E) each frame isBeingChopped = true; currentPlayer = player; if (chopProgressBar != null) chopProgressBar.gameObject.SetActive(true); Debug.Log($"{name}: Chopping started ({(int)(chopProgress * 100)}% complete)"); } // ──────────────────────────────────────────────────────────────────── // FallAndDestroy: tree "falls" (tilts), then is destroyed. // We use a coroutine to wait for the visual effect before Destroy(). // ──────────────────────────────────────────────────────────────────── private IEnumerator FallAndDestroy() { isFalling = true; isBeingChopped = false; // Hide the progress bar if (chopProgressBar != null) chopProgressBar.gameObject.SetActive(false); // Award the player their wood score if (currentPlayer != null) currentPlayer.Inventory.AddWood(woodValue); Debug.Log($"{name}: Timber! Falling over {fallDuration} seconds."); // Animate the tree tilting over using rotation over time float elapsed = 0f; Quaternion startRot = transform.rotation; Quaternion endRot = Quaternion.Euler(0, 0, 90f); // falls sideways while (elapsed < fallDuration) { elapsed += Time.deltaTime; transform.rotation = Quaternion.Lerp(startRot, endRot, elapsed / fallDuration); yield return null; // wait one frame, then continue the loop } // Clear the player's interaction target before destroying if (currentPlayer != null) currentPlayer.ClearNearbyInteractable(); // Destroy the tree GameObject // Note: script execution stops after this call Destroy(gameObject); } }
If you just wrote chopProgress += 0.01f, the chop speed would depend on the frame rate — fast on a gaming PC, unbearably slow on a laptop. Dividing by chopDuration makes it exactly that many seconds regardless of hardware.
GameManager.cs
The GameManager is a singleton — there is exactly one in the scene, and any script can reach it via GameManager.Instance. It runs the countdown timer, tracks the total score, and shows the game-over screen.
// GameManager.cs // ───────────────────────────────────────────────────────────────────────────── // A singleton manager that controls the game session: timer, score, and UI. // Any script in the scene can call GameManager.Instance.AddScore(n) to // award points without needing a direct reference to this GameObject. // ───────────────────────────────────────────────────────────────────────────── using UnityEngine; using TMPro; public class GameManager : MonoBehaviour { // ── Singleton ──────────────────────────────────────────────────────── // A static property — belongs to the CLASS, not any instance. // Set in Awake so it's available before Start() runs anywhere. public static GameManager Instance { get; private set; } // ── Inspector References ───────────────────────────────────────────── [SerializeField] private TMP_Text scoreText; [SerializeField] private TMP_Text timerText; [SerializeField] private GameObject gameOverPanel; [SerializeField] private TMP_Text finalScoreText; // ── Game Settings ──────────────────────────────────────────────────── [SerializeField] private float gameDuration = 120f; // 2 minutes // ── Runtime State ──────────────────────────────────────────────────── private int totalScore = 0; private float timeRemaining; private bool gameIsOver = false; // ──────────────────────────────────────────────────────────────────── // Awake: set up the singleton reference. // This runs before any Start(), so Instance is ready for everyone. // ──────────────────────────────────────────────────────────────────── void Awake() { // If an Instance already exists (e.g. from a previous scene load), destroy this one if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; } // ──────────────────────────────────────────────────────────────────── // Start: initialise the game session // ──────────────────────────────────────────────────────────────────── void Start() { timeRemaining = gameDuration; totalScore = 0; gameIsOver = false; if (gameOverPanel != null) gameOverPanel.SetActive(false); UpdateUI(); } // ──────────────────────────────────────────────────────────────────── // Update: count down the timer each frame // ──────────────────────────────────────────────────────────────────── void Update() { if (gameIsOver) return; timeRemaining -= Time.deltaTime; if (timeRemaining <= 0f) { timeRemaining = 0f; EndGame(); } UpdateTimerDisplay(); } // ──────────────────────────────────────────────────────────────────── // Public API — called by Inventory, Chicken, etc. // ──────────────────────────────────────────────────────────────────── public void AddScore(int points) { if (gameIsOver) return; totalScore += points; UpdateScoreDisplay(); Debug.Log($"Score: {totalScore} (+{points})"); } // ──────────────────────────────────────────────────────────────────── // UI helpers // ──────────────────────────────────────────────────────────────────── private void UpdateUI() { UpdateScoreDisplay(); UpdateTimerDisplay(); } private void UpdateScoreDisplay() { if (scoreText != null) scoreText.text = $"Score: {totalScore}"; } private void UpdateTimerDisplay() { if (timerText == null) return; // Format as MM:SS for readability int minutes = (int)(timeRemaining / 60); int seconds = (int)(timeRemaining % 60); timerText.text = $"{minutes:00}:{seconds:00}"; // Turn red in the last 30 seconds as a visual warning timerText.color = timeRemaining <= 30f ? new Color(0.9f, 0.2f, 0.1f) : Color.white; } private void EndGame() { gameIsOver = true; Debug.Log($"Game Over! Final score: {totalScore}"); if (gameOverPanel != null) gameOverPanel.SetActive(true); if (finalScoreText != null) finalScoreText.text = $"Final Score\n{totalScore}"; // Pause the game Time.timeScale = 0f; } }
GameManager.Instance is a static property — it belongs to the class itself, not any object. Any script anywhere in the scene can call GameManager.Instance.AddScore(10) without needing a serialised reference. This is the most common way to access a manager in Unity.
Inspector Wiring Checklist
All six scripts are written. Now connect them to the scene through the Inspector.
PlayerArmature
The updated PlayerFarmer uses Physics.OverlapSphere to scan for nearby objects each frame — no trigger collider, no Rigidbody, no OnTriggerEnter. The interactable objects just need any collider (which Asset Store prefabs already have). The only field to fill in the Inspector is the Prompt Text.
Each CropPatch
Each Chicken
Each Tree
FeedBag
The PlayerFarmer script handles pickup automatically using Physics.OverlapSphere — when the player walks within 1 unit of any object tagged FeedBag, it disappears and the inventory count increases. You only need to ensure the object has a collider and the correct tag.
GameManager
Testing Your Game
Test each activity independently before testing the whole game together.
Test 1 — Crop Planting
Test 2 — Feeding Chickens
Test 3 — Chopping Trees
Common Issues
| Problem | Likely Cause | Fix |
|---|---|---|
| Press E but nothing happens, no prompt | PlayerFarmer script not attached, or Prompt Text field empty | Select PlayerArmature → confirm PlayerFarmer component is listed → drag Prompt Text TMP object into its Prompt Text field |
| Prompt text never shows | Prompt Text field not assigned in PlayerFarmer Inspector | Select PlayerArmature → PlayerFarmer component → drag the TMP prompt text object from Canvas into Prompt Text field |
| "No Renderer found" on CropPatch | Asset Store prefab has its mesh on a child object — GetComponent misses it | Script now uses GetComponentInChildren — re-copy the updated CropPatch.cs from this tutorial |
| NullReferenceException on CropPatch | Material fields not assigned in Inspector | Drag all four materials into the CropPatch fields |
| Chicken doesn't animate | Animator Controller not assigned or "Eat" Trigger name mismatch | Check Animator Controller is on the chicken and Trigger is spelled "Eat" exactly |
| Progress bar doesn't appear | Chop Progress Bar field not wired in Tree Inspector | Drag the Slider from Canvas into the Tree's Chop Progress Bar field |
| Score doesn't update | GameManager.Instance is null — GameManager script not in scene | Add an empty GameObject named GameManager with the GameManager script |
| Game doesn't pause on end | Time.timeScale = 0 won't stop Unscaled coroutines | This is expected — Time.timeScale pauses physics and Update but not all coroutines |
📱 Building for Android
Putting your game on an actual Android device is one of the most satisfying moments in this course. Unity handles the heavy lifting — you configure a few settings, add mobile input controls, then build and deploy.
Steps 5A and 5B (installing modules and enabling Developer Mode on your phone) can take 10–15 minutes. Do them before you sit down to build.
5A — Install Android Build Support in Unity Hub
-
1Open Unity Hub
Go to Installs tab → find your Unity version → click the ⚙️ gear icon next to it → Add Modules.
-
2Select the Android modules
Tick: Android Build Support and inside it tick both Android SDK & NDK Tools and OpenJDK. Click Install. This downloads ~1.5 GB — use your home WiFi, not the lab.
-
3Verify the install
Reopen Unity. Go to File → Build Settings. You should see Android listed as a platform. If it shows a warning icon, the module is still installing.
5B — Enable Developer Mode on Your Android Device
-
1Find your Build Number
Settings → About Phone → Software Information → find Build Number. (Location varies by manufacturer — search "enable developer mode" + your phone model if needed.)
-
2Tap Build Number 7 times
Each tap shows a countdown. After 7 taps: "You are now a developer!" appears.
-
3Enable USB Debugging
Settings → Developer Options (now visible) → turn on USB Debugging.
-
4Connect via USB and trust the computer
Plug your phone into your PC/Mac via USB. A prompt appears on the phone: "Allow USB debugging?" → tap Allow. Tick "Always allow from this computer."
5C — Switch Platform to Android in Unity
In Build Settings, set Texture Compression to ASTC — this gives the best quality/size ratio on modern Android devices (all phones since ~2016 support it).
5D — Add Mobile Input Controls
Keyboard keys don't exist on a touchscreen. You need two things: a virtual joystick for movement and an on-screen Interact button for pressing E.
Virtual Joystick (Movement)
The Starter Assets package already includes a mobile joystick UI. Enable it:
On-Screen Interact Button
Add a button to the Canvas that calls the player's MobileInteract() method:
Hold-to-Chop Button (for Tree)
Tree chopping needs a hold, not a tap. Use the Button's EventTrigger component instead of OnClick:
OnClick() fires once on release — perfect for plant/harvest/feed (single action). PointerDown/PointerUp fires on press and release separately — necessary for chop which needs to know the button is being held.
Show/Hide Mobile Buttons Based on Platform
You want the E-key prompt on PC but the on-screen button on mobile. A simple script handles this:
// MobilePlatformUI.cs // ───────────────────────────────────────────────────────────────────────────── // Shows the on-screen buttons only when running on a touch device. // Attach to the Canvas. Drag the mobile button panel into mobileControlsPanel. // ───────────────────────────────────────────────────────────────────────────── using UnityEngine; public class MobilePlatformUI : MonoBehaviour { [SerializeField] private GameObject mobileControlsPanel; // parent of E button, chop button [SerializeField] private GameObject keyboardPromptPanel; // "Press E to ..." text panel void Awake() { // Application.isMobilePlatform is true on Android and iOS at runtime. // In the Editor it is false — so keyboard controls stay active while testing. bool isMobile = Application.isMobilePlatform; if (mobileControlsPanel != null) mobileControlsPanel.SetActive(isMobile); if (keyboardPromptPanel != null) keyboardPromptPanel.SetActive(!isMobile); } }
Install Unity Remote 5 (free on the Play Store / App Store). With your phone connected via USB: Edit → Project Settings → Editor → Device: any Android device. Press Play in Unity — your phone's screen mirrors the game and sends touch input back to Unity in real time. Much faster than building a full APK each time.
5E — Build and Deploy
Go to Settings → Apps → Special Access → Install Unknown Apps → find your file manager or ADB → enable it. This allows installing APKs outside the Play Store.
Common Android Build Errors
| Error | Cause | Fix |
|---|---|---|
No Android device found | USB Debugging not enabled, or wrong USB mode | On phone: swipe down → USB notification → select "File Transfer (MTP)" not "Charging only" |
SDK not found | Android Build Support module installed but SDK path not set | Edit → Preferences → External Tools → Android → tick "Unity" for SDK, NDK, JDK |
Gradle build failed | Package name has uppercase or spaces | Player Settings → Other Settings → Package Name must be com.name.game lowercase only |
Installation failed (INSTALL_FAILED_UPDATE_INCOMPATIBLE) | Old version on phone has different signature | Uninstall the old version from your phone, then build again |
| Game installs but controls don't work | Mobile buttons not wired to PlayerFarmer, or wrong method selected in OnClick | Re-check Inspector wiring for InteractButton and ChopButton EventTrigger components |
| Screen orientation wrong (portrait instead of landscape) | Orientation not set in Player Settings | Player Settings → Resolution → Default Orientation → Landscape Left |
You've gone from a C# script to a deployed mobile game. This is the complete pipeline every professional Unity developer uses — from prototype to device.
Extension Challenges
Finished early? These extensions are ordered from easiest to most challenging.
| ⭐ Difficulty | Challenge | New Concept |
|---|---|---|
| ⭐ | Add a second crop type (e.g. Pumpkin) with a different grow time and value | Duplicate prefab, adjust SerializeField values |
| ⭐ | Display the player's feed count and crop count on the UI | TMP_Text, referencing Inventory from GameManager |
| ⭐⭐ | Spawn a new FeedBag every 20 seconds at the farmhouse | Coroutine + Instantiate(prefab) |
| ⭐⭐ | Chickens lay an egg (small sphere) after being fed that the player can collect for bonus points | Coroutine spawning a prefab at a position |
| ⭐⭐ | Trees respawn after 60 seconds in the same position | Coroutine on a tree manager, Instantiate at stored position |
| ⭐⭐⭐ | Add a watering mechanic — crops only grow if watered within the first 5 seconds of planting | Second interaction type, condition check in GrowSequence |
| ⭐⭐⭐ | Add a day/night cycle using the Directional Light's rotation — crops grow faster during the day | Lighting, time-of-day variable, modifying coroutine wait time |
| ⭐⭐⭐⭐ | Add a simple shop at the farmhouse — sell harvested crops for gold coins using a UI panel | UI Button, ScriptableObject for item data, currency system |
You have built a complete game loop with 6 communicating scripts, a shared interface, coroutines, an Animator, a singleton manager, and a UI system — every scripting concept from Week 4 in a single project. Well done!