FutureScape

OVERVIEW

// METRICS

Engine
Unity (XR Interaction Toolkit)
My Roles
Gameplay Programmer,Game Designer
Platforms
VR, PC, WebGL

Developed for Enterprise Technology, FutureScape is a VR career exploration tool developed in Unity and lets middle schoolers try out careers across 15 STEAM industries through short, hands-on minigames. The project features interactive minigames, each attempting to emulate different careers such as pharmacology, manufacturing, forensic science, etc.

I specifically designed and developed the pharmacy, manufacturing, and urban planning minigames. In the pharmacy minigame, players act as pharmacists, dispensing medications to patients within a time limit. The manufacturing minigame challenges players to build assembly lines and create products while managing a budget. The urban planning minigame involves designing a city and managing resources and budgeting effectively.

TECHNICAL FEATURES

Pharmacy Minigame

Pharmacy Minigame

The Pharmacy minigame turns a pharmacist's job into a two-minute race. Players read each patient's symptoms, prepare the right medication and hand it over before the clock runs out.

I built the original Pharmacy game loop in late 2023 - the medication system, pill bottles, patient spawning and difficulty scaling. Teammates later added the medicine guidebook, desktop pickup tooltips and a reworked round timer. This post walks through how each piece works, with code taken straight from the project.

The player only ever touches physical objects, and the relay pad is the single doorway into the game rules. Everything the player sees afterwards is driven by events the manager fires. The code lives in 15 C# scripts under Assets/Scripts/Minigames/Pharmacy: eight gameplay scripts, one ScriptableObject definition and six UI scripts.

Core Gameplay Loop

A round starts when the player presses Start and ends when the 2-minute clock hits zero. In between, the player cycles through patients: read the symptoms, prepare a medication, place it on the pad. A wrong item is simply ignored, so the only cost of a mistake is lost time. Players look up which medication treats which symptom in the in-game medicine guidebook.

Symptoms and cures are data, not code.

Every ailment in the game is a ScriptableObject asset. A SymptomData asset holds the text the patient shows and the one medication that cures it.

// SymptomData
[CreateAssetMenu(fileName = "SymptomData", menuName = "Scriptable Objects/Data/Symptom Data", order = 1)]
public class SymptomData : ScriptableObject
{
    [TextArea(3,10)]
    public string description;

    public MedicationType cure;
}

Medications are one enum. Every grabbable tonic bottle, cream tube and pill bottle carries a Medication component that says which medication it is. Checking a cure is then a plain enum comparison.

// Medication.cs
public enum MedicationType  // List of all possible medications in the game
{
    promethazine,
    diphenhydramine,
    bismouthSubsalicylate,
    hydrocortisone,
    clotrimazole,
    triamcinolone,
    ibuprofen_pill,
    tramadol_pill,
    // ...four more pills, three half-pill variants
    emptyBottle
}

public class Medication : MonoBehaviour
{
    [SerializeField] public string medicationName;
    [SerializeField] public MedicationType medicationType;
    public Transform tooltipAnchor;
}

The game ships with 12 symptom assets. Adding an ailment takes no code: a designer creates the asset from Unity's Create menu and drops it into the game manager's list.

// Symptoms
Symptom Cure Form
Nausea and vomiting Promethazine Tonic
Allergies Diphenhydramine Tonic
Upset stomach and diarrhea Bismuth subsalicylate Tonic
Second-degree sunburn Hydrocortisone Cream
Fungus on the hands and feet Clotrimazole Cream
Eczema and itching Triamcinolone Cream
Fever Ibuprofen Pills
Pain from surgery Tramadol Pills
High blood pressure Lisinopril Pills
Cough and shortness of breath Benzonatate Pills
Stomach infection Amoxicillin Pills
Acid reflux and ulcers Lansoprazole Pills

Symptoms and the cures for those ailments.

Tonics and creams are ready to hand over as they sit on the shelf. Pills have to be counted into a bottle first, which is the game's main hands-on mechanic.

Difficulty Scaling

Each new patient gets a random name from a list of 10 and a set of random symptoms. The number of symptoms equals the current difficulty level. The manager fires an event for each symptom so the UI can fill one row per ailment.

// PharmacyGameManager.cs
private void NewPatient(int numOfSymptoms)
{
    int patientIndex = UnityEngine.Random.Range(0, patientData.Length);
    OnPatientUpdate?.Invoke(patientData[patientIndex]);

    while (numOfSymptoms > 0)
    {
        SymptomData randomSymptom = symptomData[UnityEngine.Random.Range(0, symptomData.Length)];
        currentSymptoms.Add(randomSymptom);

        OnSymptomUpdate?.Invoke(randomSymptom.description, numOfSymptoms);
        numOfSymptoms--;
    }
}

Difficulty runs from 1 symptom up to MAX_SYMPTOM_COUNT, which is 3. After every third cure, the next patient walks in with one more symptom. A coroutine handles the hand-off between patients:

// PharmacyGameManager
private IEnumerator PatientDelayCo()
{
    curedParticles.Play();
    curedSFX.pitch = UnityEngine.Random.Range(.8f, 1.2f);
    curedSFX.Play();

    yield return new WaitForSeconds(patientDelay);  // 0.5 seconds
    relayPad.ClearSockets();

    OnPatientCured?.Invoke();

    if ((currentDifficulty < MAX_SYMPTOM_COUNT) &&
        (patientCount % increaseDifficultyAfter == 0))  // every 3 cures
        currentDifficulty++;

    NewPatient(currentDifficulty);
}

The short delay lets the cure particles and sound play before the next patient appears. Randomizing the pitch between 0.8 and 1.2 keeps a sound the player hears dozens of times from feeling mechanical. The patient is a prefab that PatientSpawner instantiates at the door and hands a waypoint. A small Patient script walks it to the counter and switches the Animator out of its walk cycle on arrival:

// Patient
private void Update()
{
    if (waypoint && isWalking)
    {
        if ((transform.position - waypoint.position).magnitude <= Mathf.Epsilon)
        {
            isWalking = false;
            anim.SetBool("isWalking", false);
        }

        transform.position = Vector3.MoveTowards(transform.position, waypoint.position, speed);
    }
}

Pill Bottle Filling

Pills sit loose in trays as round tablets and capsules. To dispense them, the player drops pills one by one into an empty bottle. The bottle's trigger collider counts each pill, and the first pill in decides what the bottle will become.

// PillBottle.cs
private void OnTriggerEnter(Collider other)
{
    if (!pillEvalFlag && pillCount < requiredPillCount && other.gameObject.GetComponent<Pill>() != null)
    {
        Pill pill = other.gameObject.GetComponent<Pill>();

        // The first pill sets the bottle's type
        if (currentMedicationType == MedicationType.emptyBottle)
        {
            currentMedicationType = pill.pillType;
            OnNewPill?.Invoke(pill.pillSprite);  // show this pill's icon

            Destroy(other.gameObject);
            pillCount++;
        }
        else if (currentMedicationType == pill.pillType)
        {
            Destroy(other.gameObject);
            pillCount++;
        }
        CheckBottle();
        OnPillUpdate?.Invoke(pillCount, requiredPillCount);
    }
}

A pill of a different type is never destroyed or counted, so a bottle can't end up holding a mix. Once five matching pills are in, CheckBottle writes the final type onto the bottle's Medication component and puts the cap on.

// PillBottle.cs
private void CheckBottle()
{
    if (pillCount >= requiredPillCount)
    {
        medication.medicationType = currentMedicationType;
        bottleCap.SetActive(true);
    }
}

This keeps delivery simple. A filled bottle is just another Medication, so the delivery pad checks it exactly like a tonic or a cream. An unfinished bottle still reads emptyBottle, which cures nothing. A small world-space counter floats over the bottle and shows progress like "3/5" next to the pill's icon. It plays a random pill-drop sound on each update and copies the camera's rotation every frame so it always faces the player.

// PillBottle.cs
public void UpdateText(int pillCount, int reqPillCount)
{
    gameObject.GetComponent<AudioSource>().PlayOneShot(clips[Mathf.RoundToInt(Random.Range(0, clips.Length - 1))]);
    pillText.text = pillCount.ToString() + "/" + reqPillCount.ToString();
}

private void Update()
{
    transform.rotation = GameObject.FindObjectOfType<AudioListener>().transform.rotation;
}

Relay Pad

A pad on the counter sits between the player and the patient. When anything lands on it, the pad looks for a Medication component and passes it to the game manager.

// RelayPad
private void OnCollisionEnter(Collision other)
{
    if (!other.gameObject.GetComponent<Medication>())
        return;

    Medication medication = other.gameObject.GetComponent<Medication>();
    PharmacyGameManager.instance.CheckMedication(medication);
}

The manager compares the medication against every symptom the patient still has. On a match, it removes that symptom, asks the pad to lock the item in place and checks whether the patient is fully cured.

// PharmacyGameManager
public void CheckMedication(Medication medication)
{
    SymptomData foundSymptom = null;

    foreach (SymptomData sData in currentSymptoms)
    {
        if (medication.medicationType == sData.cure)
        {
            foundSymptom = sData;
            relayPad.SocketMedication(medication.gameObject);
        }
    }

    if (foundSymptom)
    {
        currentSymptoms.Remove(foundSymptom);
        CheckPatientCured();
    }
}

Socketing snaps the item to the next free socket point and takes it out of play. Disabling its XRGrabInteractable stops a VR player from grabbing it back. Making the rigidbody kinematic stops physics from knocking it off the counter.

// RelayPad
public void SocketMedication(GameObject medication)
{
    if (socketIndex < socketPoints.Length)
    {
        medicationsOnCounter.Add(medication);

        medication.transform.position = socketPoints[socketIndex].position;
        medication.transform.rotation = socketPoints[socketIndex].rotation;

        medication.GetComponent<XRGrabInteractable>().enabled = false;
        medication.GetComponent<Rigidbody>().isKinematic = true;

        socketIndex++;
    }
}

A wrong medication is never socketed, so it stays loose on the pad for the player to pick back up. When the patient is cured, ClearSockets destroys everything on the counter and resets the socket index for the next patient.