
Close the core game loop
Tutorial
·
Beginner
·
+0XP
·
30 mins
·
Unity Technologies
In this tutorial, you’ll close the game loop by introducing win/lose conditions.
By the end this this tutorial, you’ll be able to do the following:
- Create a GameManager to control the states of the game.
- Create a new UI for the End screens.
- Display the End screens once each condition is met.
1. Overview
In this tutorial, you’ll close the core game loop of your game by creating a GameManager script to handle the UI for the win and lose conditions so the player receives clear visual feedback when these events occur.
Note: For the purposes of this tutorial, we chose to use the Ruby’s Adventure asset set, and the images used in instructions will reflect this. If you chose another asset set, the file names will be the same, but in your corresponding theme folder.
2. What is a game loop?
In game design, the core game loop refers to the repeating cycle of actions and feedback that keeps the player engaged throughout the game. It’s essentially the structure that defines what the player does over and over to progress in your game or reach new goals.
In adventure games, the core loop often follows this general flow:
- Explore the world: The player moves through environments, discovering new areas and interacting with objects or characters.
- Complete tasks or challenges: They might solve puzzles, fix broken items, or help NPCs.
- Earn rewards or unlock progress: Completing these tasks completes the game, grants items, or opens new zones.
This continuous cycle (exploration, interaction, and reward/progression) forms the core experience of the game. Win and lose conditions (like completing all objectives or losing all health) define when this loop ends, while smaller loops (like combat or dialogue) live inside the main one to add depth to it.

This project follows a simple core game loop: the game restarts either when you fix all the robots and see the Win UI screen, or when your health reaches 0 and the Lose UI screen appears.
Once you complete this project, we encourage you to expand on this foundation by adding more complexity to your game loop. For now, we’ll focus on guiding you through one of the simplest and most effective approaches.
3. Create Win/Lose UI screens
The first thing you’re going to do is set up the UI screens for each condition. You’ve previously set up other UI elements like the Health bar and the Dialogue window, so you’ll have familiarity with the process, but we’ll give more direct instructions.
To set up the End UI screens, follow these instructions:
1. In the Project window, create a new UIDocument asset and name it “EndScreen”.
2. Double-click the EndScreen asset to open it in the UI Builder window.
3. Add one VisualElement and rename it “LoseScreenContainer”, then add another VisualElement as a child element and rename it “LoseScreen”.
4. Configure the LoseScreenContainer and its child LoseScreen in the following way:
For the LoseScreenContainer element:
- In the Display section, set the Opacity to 0.
- In the Position section, change the Position Mode to Absolute and set all the Anchor values to 0.
- In the Background section, set the Color to Black, and its A (alpha) channel to 255.
- In the Transition Animations section, set the Property to opacity, the Duration to 1, and the Easing to Ease.
Note: Transition Animations control how style properties change visually over time. In this case: the Property defines which attribute is being animated (opacity). The Duration determines how long the transition takes (1 second). The Easing defines the acceleration curve (Ease – which makes the fade start slowly, speed up, and then slow down at the end).
For the LoseScreen element:
- In the Background section, set the Image type to Sprite, and using the selection box, select the LoseScreen sprite provided.
- In the Background section, set the Scale Mode to Scale to Fit.
5. Repeat steps 3 and 4 to create the WinScreenContainer and child element accordingly, use the WinScreen sprite for the background image instead.
6. Open the GameUI UI Document in the UI Builder window, and add an instance of the new EndScreen UI Document as a child element, just like you did with NPCDialogue in Update the GameUI asset.
7. Open the Inspector pane and expand the Position section. Set the Position Mode to Absolute, then set all Anchors (Top, Left, Right, Bottom) to 0 so the EndScreen fills the entire screen.
4. Update the UIHandler script
Next, you’ll update the UIHandler script so it can store references to the new EndScreen elements and provide functions to display them when needed.
To update the UIHandler script, follow these steps:
1. Open the UIHandler script, and at the top of the UI Handler class declare two new private variables:
private VisualElement m_WinScreen;
private VisualElement m_LoseScreen;2. Initialize both variables inside the Start method:
m_LoseScreen = uiDocument.rootVisualElement.Q<VisualElement>("LoseScreenContainer");
m_WinScreen = uiDocument.rootVisualElement.Q<VisualElement>("WinScreenContainer");3. Create the following two new functions to display the Win and Lose screens accordingly:
public void DisplayWinScreen()
{
m_WinScreen.style.opacity = 1.0f;
}
public void DisplayLoseScreen()
{
m_LoseScreen.style.opacity = 1.0f;
}
Both of these functions only set the opacity of the corresponding UI elements to 1, making them fully visible. Earlier, you set both the LoseScreenContainer and WinScreenContainer’ opacity to 0, which keeps them hidden until these functions get called.
5. Create the GameManager script
A GameManager acts as the central controller of your game. It keeps track of important game states – such as when the player wins or loses – and coordinates what should happen in each case; in this case, showing UI screens and restarting the game.
To create a GameManager, follow these instructions:
1. Create a new empty GameObject in the scene and name it “Game Manager”. In the Inspector window, set its Transform component’s Position property values to X = 0, Y = 0, and Z = 0.
2. In the Project window, create a new MonoBehaviour script, name it “GameManager”, attach it to your GameManager GameObject, and open it in your IDE.
3. At the top of the GameManager script, beneath the existing namespace and before the class declaration, add the following line of code:
using UnityEngine.SceneManagement;This instruction tells the computer that the script needs to use the SceneManagement package’s features and functionality – this is needed for reloading the scene after the win/lose conditions are either met.
4. At the top of the GameManager class, declare the following variables:
public PlayerController player;
EnemyController[] enemies;
public UIHandler uiHandler;Here’s an explanation of this code:
- You need a reference to the PlayerController script to access the PlayerCharacter’s health, since the lose condition occurs when health reaches 0.
- You’ll also gather a list of all the enemies to determine when they’ve all been fixed – this will define the win condition.
- Finally, you need a reference to the UIHandler script to display the appropriate UI screen once either condition is met.
5. In the Start function, add the following line of code:
enemies = FindObjectsByType<EnemyController>(FindObjectsSortMode.None); The FindObjectsByType method retrieves a list of all GameObjects in the scene that have a particular component – in this case, all GameObjects that have the Enemy script attached to them. This method requires a parameter to define how the elements in the list should be sorted. Since sorting doesn’t matter in this situation, you can set it to None.
6. Create a new function to handle reloading the scene:
void ReloadScene()
{
SceneManager.LoadScene(SceneManager.GetActiveScene().name);
}The SceneManager.LoadScene() function tells Unity to reload a specified scene. In this case, you’re using SceneManager.GetActiveScene().name to pass the name of the current scene, which means the entire level will restart from the beginning – as if you were starting it from scratch.
You’ll define where to call this new function and use the information from the variables you just declared in the next step.
6. Define the win and lose conditions
We’ll check the win and lose conditions in the GameManager every frame to determine whether the player’s health has reached 0 or if all the robots have been fixed.
To define the win and lose conditions, follow these instructions:
Lose condition
1. Open your GameManager script in your IDE.
2. In the Update function, add the following lines of code:
if (player.health <= 0)
{
uiHandler.DisplayLoseScreen();
Invoke(nameof(ReloadScene), 3f);
}
Here’s an explanation of this code:
- The if condition checks whether the PlayerCharacter’s health has reached 0. If this is true, two things happen:
- The DisplayLoseScreen function from the UIHandler script is called, in Update the UIHandler, you defined this function to show the Lose UI screen.
- A new structure you haven’t used before appears – the Invoke method. This method allows you to execute a specific function after a delay. You pass in the function’s name as a string, along with the delay time in seconds. In this case, you use the nameof keyword to retrieve the name of the ReloadScene function as a string, and call it 3 seconds later.
Win condition
1. Open your EnemyController script in your IDE, and at the top of the Enemy Controller class, declare a new variable:
public bool isBroken { get { return broken; }}In Define a property in the PlayerController script, you created a read-only property for the player’s currentHealth variable. In this case, you’ll apply the same idea: you only need to read whether an enemy is still broken or not. The isBroken variable will now store and expose that information to other scripts, without allowing changes to its value.
2. Save your changes.
3. Open the GameManager script In the Update function, and below the code you added for the Lose condition, add the following line of code:
if (AllEnemiesFixed())
{
uiHandler.DisplayWinScreen();
Invoke(nameof(ReloadScene), 3f);
}
4. Create the following new AllEnemiesFixed function:
bool AllEnemiesFixed()
{
foreach (EnemyController enemy in enemies)
{
if (enemy.isBroken) return false;
}
return true;
}
Here’s an explanation of this code:
- You previously stored the list of all enemies in the scene in the enemies variable. Now, the AllEnemiesFixed() function goes through that list using a foreach loop and checks if any of the enemies still have their isBroken variable set to true – meaning they haven’t been fixed yet. Only if all enemies are fixed, the function returns true.
- This function is then called in the Update() method each frame. When it returns true, two things happen – just like in the lose condition:
- The DisplayWinScreen() function from the UIHandler script is called.
- The ReloadScene() function is invoked after 3 seconds to restart the scene.
5. Save your scripts, then return to the Unity Editor. Select the Game Manager GameObject and, in the Inspector window, assign the PlayerCharacter and UIDocument GameObjects from the Hierarchy window to the corresponding Player and UiHandler properties.
6. Enter Play mode and test your game!
Try meeting both win/lose conditions – losing all your health and fixing all the robots – to confirm that each end screen appears correctly when triggered.
7. More things to try
If you want to further develop your skills, explore new concepts, or improve your project, check out some of the optional activities below. Each one is tagged as either Easy, Medium, or Difficult, so you can choose the level of challenge.
These activities are entirely optional, so if you’re not interested, no problem – just skip this step. We do recommend attempting at least one of them in order to get the most out of this learning experience. Good luck!
Easy: Switch the images for the End screens
We’ve provided example images for the end screens, but you’re welcome to design your own. You can refer to the UI Toolkit documentation to learn how to add and customize additional visual elements, allowing you to personalize your win and lose screens as you like.
8. Check your scripts
GameManager.cs
using UnityEngine;
using UnityEngine.SceneManagement;
public class GameManager : MonoBehaviour
{
public PlayerController player;
EnemyController[] EnemyController;
public UIHandler uiHandler;
void Start()
{
enemies = FindObjectsByType<Enemy>(FindObjectsSortMode.None);
}
void Update()
{
// Lose condition
if (player.health <= 0)
{
uiHandler.DisplayLoseScreen();
Invoke(nameof(ReloadScene), 3f);
}
// Win condition
if (AllEnemiesFixed())
{
uiHandler.DisplayWinScreen();
Invoke(nameof(ReloadScene), 3f);
}
}
bool AllEnemiesFixed()
{
foreach (Enemy enemy in enemies)
{
if (enemy.isBroken) return false;
}
return true;
}
void ReloadScene()
{
SceneManager.LoadScene(SceneManager.GetActiveScene().name);
}
}
EnemyController.cs
using UnityEngine;
public class EnemyController : MonoBehaviour
{
bool broken = true;
public bool isBroken { get { return broken; }}
// Public variables
public float speed;
public bool vertical;
public float changeTime = 3.0f;
// Private variables
Rigidbody2D rigidbody2d;
Animator animator;
float timer;
int direction = 1;
// Start is called once before the first execution of Update after the MonoBehaviour is created
void Start()
{
rigidbody2d = GetComponent<Rigidbody2D>();
animator = GetComponent<Animator>();
timer = changeTime;
}
void Update()
{
timer -= Time.deltaTime;
if (timer < 0)
{
direction = -direction;
timer = changeTime;
}
}
// FixedUpdate has the same call rate as the physics system
void FixedUpdate()
{
if (!broken)
{
return;
}
Vector2 position = rigidbody2d.position;
if (vertical)
{
position.y = position.y + speed * direction * Time.deltaTime;
animator.SetFloat("Move X", 0);
animator.SetFloat("Move Y", direction);
}
else
{
position.x = position.x + speed * direction * Time.deltaTime;
animator.SetFloat("Move X", direction);
animator.SetFloat("Move Y", 0);
}
rigidbody2d.MovePosition(position);
}
public void Fix()
{
broken = false;
rigidbody2d.simulated = false;
animator.SetTrigger("Fixed");
}
void OnTriggerEnter2D(Collider2D other)
{
PlayerController player = other.gameObject.GetComponent<PlayerController>();
if (player != null)
{
player.ChangeHealth(-1);
}
}
}
UIHandler.cs
using UnityEngine;
using UnityEngine.UIElements;
public class UIHandler : MonoBehaviour
{
public static UIHandler instance { get; private set; }
public float displayTime = 4.0f;
private VisualElement m_NonPlayerDialogue;
private float m_TimerDisplay;
private VisualElement m_Healthbar;
private VisualElement m_WinScreen;
private VisualElement m_LoseScreen;
private void Awake()
{
instance = this;
}
void Start()
{
UIDocument uiDocument = GetComponent<UIDocument>();
m_Healthbar = uiDocument.rootVisualElement.Q<VisualElement>("Healthbar");
SetHealthValue(1.0f);
m_NonPlayerDialogue = uiDocument.rootVisualElement.Q<VisualElement>("NPCDialogue");
m_NonPlayerDialogue.style.display = DisplayStyle.None;
m_TimerDisplay = -1.0f;
m_LoseScreen = uiDocument.rootVisualElement.Q<VisualElement>("LoseScreenContainer");
m_WinScreen = uiDocument.rootVisualElement.Q<VisualElement>("WinScreenContainer");
}
public void SetHealthValue(float percentage)
{
m_Healthbar.style.width = Length.Percent(100 * percentage);
}
private void Update()
{
if (m_TimerDisplay > 0)
{
m_TimerDisplay -= Time.deltaTime;
if (m_TimerDisplay < 0)
{
m_NonPlayerDialogue.style.display = DisplayStyle.None;
}
}
}
public void DisplayDialogue()
{
m_NonPlayerDialogue.style.display = DisplayStyle.Flex;
m_TimerDisplay = displayTime;
}
public void DisplayWinScreen()
{
m_WinScreen.style.opacity = 1.0f;
}
public void DisplayLoseScreen()
{
m_LoseScreen.style.opacity = 1.0f;
}
}
9. Next steps
You’ve created a functional 2D adventure game, but there’s much more you can do to enhance it even further. In the next unit, you’ll improve the overall player experience with a dynamic camera, game audio, visual effects, and extra features to implement. You’ll then build your game!