Timeline
20 weeks
Apr - Aug '24
Genre
Third Person
Adventure
Education
Responsibilities
Team Lead
Gameplay Programmer
UI Designer
Prototyper
Tech
Unity
C#
Back
Third Person / Adventure / Education - August 2024
View on Github
OVERVIEW
Timeline
20 weeks
Apr - Aug '24
Genre
Third Person
Adventure
Education
Responsibilities
Team Lead
Gameplay Programmer
UI Designer
Prototyper
Tech
Unity
C#
CPU City follows the journey of Rhi Vyse, a determined teenager living in a vibrant city powered by technology. When the mysterious Pro-Krastor Nation begins to sabotage the city's systems, Rhi must step up to stop their plans by navigating the city to uncover its secrets; the Von Neumann Architecture that keeps it running. The educational adventure teaches computing concepts through a hero-driven narrative set in a colorful, fun, and engaging world.
BACKGROUND
The idea for CPU City came from a collaboration between myself and teammate Jess, a GCSE Computer Science teacher. While I was passionate about edutainment, Jess brought deep insight into the classroom struggles students faced. Together, we saw an opportunity to turn difficult concepts into something playful, accessible, and fun. Jess also provided direct access to our target audience, allowing for meaningful playtesting throughout development.
We envisioned our game as a long-term project; a gamified city where each level teaches a different part of the GCSE Computer Science syllabus. For this version, we focused on a single level covering the Von Neumann Architecture, one of the first topics students encounter. This choice allowed us to illustrate the game's full potential while keeping the project scoped and achievable.
PROGRAMMING
dialogue.cs
interaction.cs
quest.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;
using Cinemachine;
public class DialogueManager : MonoBehaviour
{
public TextMeshProUGUI actorName;
public TextMeshProUGUI messageText;
public RectTransform backgroundBox;
public Animator dialogueAnimator;
public OneWheelMovement playerController;
public CinemachineFreeLook freeLookCamera;
private string originalXAxisName;
private string originalYAxisName;
Message[] currentMessages;
Actor[] currentActors;
int activeMessage = 0;
public bool inConversation = false;
bool isWritingMessage = false;
public float dialogueSpeed;
private bool startAnimation = true;
private bool questDialogue;
private int givenQuestIndex;
void Start()
{
dialogueSpeed = 0.04f;
originalXAxisName = freeLookCamera.m_XAxis.m_InputAxisName;
originalYAxisName = freeLookCamera.m_YAxis.m_InputAxisName;
}
void Update()
{
if (inConversation)
{
if (startAnimation)
{
dialogueAnimator.SetTrigger("Enter");
startAnimation = false;
}
else
{
// This skips the animation if you press space
if (isWritingMessage)
{
if (Input.GetKeyDown(KeyCode.Space))
{
dialogueSpeed = 0f;
}
}
else
{
dialogueSpeed = 0.04f;
}
if (Input.GetKeyDown(KeyCode.Space) && !isWritingMessage)
{
NextMessage();
}
}
}
}
public void OpenDialogue(Message[] messages, Actor[] actors)
{
questDialogue = false;
currentMessages = messages;
currentActors = actors;
activeMessage = 0;
inConversation = true;
playerController.allowMovement = false;
freeLookCamera.m_XAxis.m_InputAxisName = "";
freeLookCamera.m_YAxis.m_InputAxisName = "";
DisplayMessage();
}
public void OpenDialogue(Message[] messages, Actor[] actors, int questIndex)
{
if(questIndex > 0)
{
givenQuestIndex = questIndex;
questDialogue = true;
}
else
{
questDialogue = false;
}
currentMessages = messages;
currentActors = actors;
activeMessage = 0;
inConversation = true;
playerController.allowMovement = false;
freeLookCamera.m_XAxis.m_InputAxisName = "";
freeLookCamera.m_YAxis.m_InputAxisName = "";
DisplayMessage();
}
void DisplayMessage()
{
Message messageToDisplay = currentMessages[activeMessage];
Actor actorToDisplay = currentActors[messageToDisplay.actorId];
messageText.text = "";
StartCoroutine(WriteSentence(messageToDisplay.message));
actorName.text = actorToDisplay.name;
}
public void NextMessage()
{
activeMessage++;
if(activeMessage < currentMessages.Length)
{
DisplayMessage();
}
else
{
if (questDialogue && (GameManager.Instance.currentQuestIndex == givenQuestIndex - 1))
{
GameManager.Instance.currentQuestIndex = givenQuestIndex;
}
inConversation = false;
startAnimation = true;
dialogueAnimator.SetTrigger("Exit");
startAnimation = true;
playerController.allowMovement = true;
freeLookCamera.m_XAxis.m_InputAxisName = originalXAxisName;
freeLookCamera.m_YAxis.m_InputAxisName = originalYAxisName;
}
}
IEnumerator WriteSentence(string message)
{
isWritingMessage = true;
foreach(char Character in message.ToCharArray())
{
messageText.text += Character;
yield return new WaitForSeconds(dialogueSpeed);
}
isWritingMessage = false;
}
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PlayerInteract : MonoBehaviour
{
void Update()
{
if(Input.GetKeyDown(KeyCode.E)){
float interactRange = 2f;
Collider[] colliderArray = Physics.OverlapSphere(transform.position, interactRange);
foreach(Collider collider in colliderArray){
if(collider.TryGetComponent(out NPCInteractable npcInteractable)){
npcInteractable.Interact();
}
}
}
}
public NPCInteractable GetInteractableObject()
{
List<NPCInteractable> npcInteractableList = new List<NPCInteractable>();
float interactRange = 2f;
Collider[] colliderArray = Physics.OverlapSphere(transform.position, interactRange);
foreach (Collider collider in colliderArray)
{
if (collider.TryGetComponent(out NPCInteractable npcInteractable))
{
npcInteractableList.Add(npcInteractable);
}
}
NPCInteractable closestNPCInteractable = null;
foreach(NPCInteractable npcInteractable in npcInteractableList)
{
if(closestNPCInteractable == null)
{
closestNPCInteractable = npcInteractable;
}
else
{
if (Vector3.Distance(transform.position, npcInteractable.transform.position) < Vector3.Distance(transform.position, closestNPCInteractable.transform.position))
{
closestNPCInteractable = npcInteractable;
}
}
}
return closestNPCInteractable;
}
}
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;
using UnityEngine.UI;
using UnityEngine.SceneManagement;
public class GameManager : MonoBehaviour
{
public static GameManager Instance { get; private set; }
public TextMeshProUGUI questText;
public TextMeshProUGUI timerText;
public Image timerImage;
public GameObject timerObject;
public string[] quests = { "Talk to Copper", "Check in with PC Reg Ister at the Police Station", "Talk to Marvin about the pigeon", "Speak to the caretaker at the RAM headquarters", "Collect the pigeon in location 38", "Talk to Copper", "All tasks done!" };
public int currentQuestIndex = 0;
private float timerDuration = 600f;
private float timeRemaining;
private bool timerActive = false;
private bool timerStarted = false;
public Color startColor = Color.white;
public Color endColor = Color.red;
private bool endOfGame = false;
private void Awake()
{
if (Instance == null)
{
Instance = this;
DontDestroyOnLoad(gameObject);
SceneManager.sceneLoaded += OnSceneLoaded;
}
else
{
Destroy(gameObject);
}
}
void FixedUpdate()
{
DisplayMainQuests();
if(currentQuestIndex == quests.Length - 1 && !Initiate.areWeFading && !endOfGame)
{
timerActive = false;
endOfGame = true;
Initiate.Fade("11_Congratulations", new Color(105f / 255f, 131f / 255f, 204f / 255f), 0.5f);
}
if(timerActive)
{
timerObject.SetActive(true);
UpdateTimer();
}
else
{
if(currentQuestIndex != quests.Length - 1)
timerObject.SetActive(false);
}
}
private void DisplayMainQuests()
{
if (currentQuestIndex <= quests.Length)
{
questText.text = quests[currentQuestIndex];
if (currentQuestIndex > 0 && !timerActive && !timerStarted)
{
StartTimer();
timerStarted = true;
}
}
}
private void StartTimer()
{
timeRemaining = timerDuration;
timerActive = true;
}
private void UpdateTimer()
{
if (timeRemaining > 0)
{
timeRemaining -= Time.deltaTime;
DisplayTime(timeRemaining);
}
else
{
timeRemaining = 0;
timerActive = false;
DisplayTime(timeRemaining);
TimerHasEnded();
}
}
private void DisplayTime(float time)
{
int minutes = Mathf.FloorToInt(time / 60);
int seconds = Mathf.FloorToInt(time % 60);
timerText.text = string.Format("{0:00}:{1:00}", minutes, seconds);
timerImage.fillAmount = timeRemaining / timerDuration;
Color timerColor = Color.Lerp(endColor, startColor, time / timerDuration);
timerImage.color = timerColor;
}
private void TimerHasEnded()
{
Debug.Log("Timer has ended!");
SceneManager.LoadScene("12_TimesUp");
}
private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
{
if (currentQuestIndex == 4)
{
currentQuestIndex = 5;
}
if (currentQuestIndex != quests.Length - 1)
{
questText = GameObject.Find("Quest Text").GetComponent<TextMeshProUGUI>();
timerText = GameObject.Find("Timer Text").GetComponent<TextMeshProUGUI>();
timerImage = GameObject.Find("Timer Bar").GetComponent<Image>();
timerObject = GameObject.Find("Timer");
DisplayMainQuests();
}
}
private void OnDestroy()
{
SceneManager.sceneLoaded -= OnSceneLoaded;
}
public void RestartGameTimer()
{
currentQuestIndex = 0;
endOfGame = false;
timerStarted = false;
timerActive = false;
timeRemaining = timerDuration;
DisplayMainQuests();
}
}
CPU City.exe
How It Works
The DialogueManager controls all in-game conversations by linking the text display, camera behavior, and player movement. When a dialogue starts, it disables player controls and camera input, then animates the UI and types out each message character-by-character. Players can press space to skip or advance messages. The system supports both regular and quest-related dialogues, updating the player’s quest progress if needed. Once the conversation ends, control is handed back to the player.
The PlayerInteract script enables player interaction with nearby NPCs. When the player presses E, it checks for any NPCInteractable objects within a short radius using a physics overlap sphere. If one is found, it calls their Interact() method. The GetInteractableObject function returns the closest interactable NPC in range; useful for UI prompts or targeting the nearest character.
This script manages the game’s linear quest progression. It updates the current quest display based on a predefined list and starts a countdown timer once the first quest is completed. The timer appears only during active quests and adds urgency by transitioning from white to red as time runs out. If the timer reaches zero, the game shifts to a “time’s up” scene. When the player completes all quests, it transitions to a final “congratulations” scene, effectively signaling the end of the game.
FEEDBACK
92% positive feedback from testers in post-play surveys
3 out of 4 testers found VNA more enjoyable after play
40% of total players replayed the level at least once
"I found myself having fun without realizing" - Anonymous playtester
NEXT GAME