Intro

Timeframe: 6 weeks

I wrote narrative and programmed a 3rd-person dark, comedic, narrative puzzle game about a robot alone in space (Unity's HDRP) — working on a team of 19 members.

My goals were to integrate systems into gameplay and polish the UX of the save-load & UI systems.

My primary technical contributions were design and implementation of UI, localization, save-load system, procedural obstacle generation, player movement, and cinematics.


Game Summary

Gameplay & UI

I worked with a visual designer to implement 3D holograms for the intro menu and 2D designs into the in-game menu. To create a more immersive UX for the player as they first enter the game we decided to integrate both a 3D and 2D UI. It was challenging to work with an animated 3D asset that changes dynamicaly to players menu selection. I had to pair the rotating morphing 3D hologram models with the active menu selection and load a new scene after the game had started.

In-game, I continued with this visual designer to integrate a constant 2D UI that displays current task, direction of task, and current tool. I also implemented a pause menu and replayable audiolog menu to help the player replay the audio information they may decide to collect.

Below is the UI for the pause menu with functionality to change language and sound. I coded the language change functionality by creating an array of languages that could be exchanged upon language selection confirmation. GUI elements include: current objective arrow direction pointer, task objectives (in text), current tail attachment (symbol), replayable audio log menu (collected + uncollected)

Below is the starting level of the game where the player finds an introductory audio log to the central narrative and can experiment with buttons to open the door to the next level. I iterated on the button puzzle location and spacing in the intro location to improve player accessibility and encourage exploration.

Below is a central platforming puzzle that uses a tail attachment to move obstacles and the novel movement system to navigate the spaceship's cargo bay. I tested the tail attachment controls to iterate on ideal obstacle size and placement to adjust difficulty.

Project Contributions

Game UX and Programming
User Interface
Save - Load System

I developed a Save - Load system with a manager and saveable interface.

CODE SNIPPET - Save Load

The following code demonstrates the manager code used to save game data


// This class keeps track of current state of game data, organizes save and load logic
public class SaveableManager : MonoBehaviour
{
    [Header("Debugging")]
    [SerializeField] private bool disableDataPersistence = false;
    private bool initializeDataIfNull = true;
    public bool printDebugs = false;

    private string fileName = "SaveData.json";
    private bool useEncryption = false;

    [Header("Auto Saving Configuration")] [SerializeField]
    private float autoSaveTimeSeconds = 60f;
    public bool enableAutoSave = false;

    [HideInInspector]
    private Coroutine autoSaveCoroutine;

    // need a data handler to save game
    [HideInInspector]
    private FileDataHandler dataHandler;
    // list of saveable objects
    [HideInInspector]
    private List dataPersistenceObjects;
    [HideInInspector]
    private GameData gameData;
    [HideInInspector]
    private string selectedProfileId = null;
    private string defaultProfileName = "SavedGame";



    // only want one SaveableManager in scene (Singleton), can get instance publically but only modify it here
    public static SaveableManager instance { get; private set; }

    public void StartSM()
    {
        // if there is an instance of this class error
        if (instance != null)
        {
            printMsg("Found more than one Data Persistence Manager in the scene. Destroying the newest one.");
            Destroy(gameObject);
            return;
        }

        instance = this;
        //DontDestroyOnLoad(gameObject);

        if (disableDataPersistence) Debug.LogWarning("Data Persistence is currently disabled!");

        dataHandler = new FileDataHandler(Application.persistentDataPath, fileName, useEncryption);

        InitializeSelectedProfileId();
    }

    private void UpdateSM()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            printMsg("Saving Game ");
            SaveGame();
        }

        if (Input.GetKeyDown(KeyCode.L))
        {
            printMsg("Loading Game ");
            LoadGame();
        }

        if (Input.GetKeyDown(KeyCode.N))
        {
            printMsg("New Game ");
            NewGame();
        }

        if (Input.GetKeyDown(KeyCode.P)) printMsg(JsonUtility.ToJson(gameData));
    }

    private void OnEnable()
    {
        SceneManager.sceneLoaded += OnSceneLoaded;
    }

    private void OnDisable()
    {
        SceneManager.sceneLoaded -= OnSceneLoaded;
    }

    private void OnApplicationQuit()
    {
        //SaveGame();
    }

    public void OnSceneLoaded(Scene scene, LoadSceneMode mode)
    {
        dataPersistenceObjects = FindAllDataPersistenceObjects();
        //LoadGame();

        // start up the auto saving coroutine
        if (!enableAutoSave) return;
        if (autoSaveCoroutine != null) StopCoroutine(autoSaveCoroutine);
        autoSaveCoroutine = StartCoroutine(AutoSave());
    }

    public void ChangeSelectedProfileId(string newProfileId)
    {
        // update the profile to use for saving and loading
        selectedProfileId = newProfileId;
        // load the game, which will use that profile, updating our game data accordingly
        LoadGame();
    }

    public bool CanLoadGame(){
        // return if data persistence is disabled
        if (disableDataPersistence) return false;

        // load any saved data from a file using the data handler
        gameData = dataHandler.Load(selectedProfileId);

        // check if game data actually exits
        if (gameData != null) return true;
        else return false;
    }

    public void DeleteProfileData(string profileId)
    {
        // delete the data for this profile id
        dataHandler.Delete(profileId);
        // initialize the selected profile id
        InitializeSelectedProfileId();
        // reload the game so that our data matches the newly selected profile id
        LoadGame();
    }

    private void InitializeSelectedProfileId()
    {
        selectedProfileId = dataHandler.GetMostRecentlyUpdatedProfileId();
        if (string.IsNullOrWhiteSpace(selectedProfileId)){
            selectedProfileId = defaultProfileName;
        }
    }

    // create new GameData object
    public void NewGame()
    {
        gameData = new GameData();
    }

    // loads game by passing each object in the ISaveable interface and calling its LoadData function in its script from GameData
    public bool LoadGame()
    {
        // return if data persistence is disabled
        if (disableDataPersistence) return false;

        // load any saved data from a file using the data handler
        gameData = dataHandler.Load(selectedProfileId);

        // start a new game if the data is null and we're configured to initialize data for debugging purposes


        // if no data can be loaded, stop
        if (gameData == null){
            if (initializeDataIfNull){
                NewGame();
                printMsg("GameData was null; New Game created");
            }
            else {
                printMsg("Error loading:No data was found. A New Game needs to be started before data can be loaded.");
                return false;
            }
        }

        //update list of objects in the scene
        dataPersistenceObjects = FindAllDataPersistenceObjects();
        if (dataPersistenceObjects == null){
            printMsg("Error loading: there are no data persistence objects in scene");
            return false;
        }
        // push the loaded data to all other scripts that need it
        foreach (var dataPersistenceObj in dataPersistenceObjects)
        {
            dataPersistenceObj.LoadData(gameData);
        }

        printMsg("Loading game from disk completed.");
        return true;
    }


    // saves game by passing each object in the ISaveable interface and calling its SaveData function in its script
    // these scripts modify GameData object
    public void SaveGame()
    {
        // return right away if data persistence is disabled
        if (disableDataPersistence) return;

        // if we don't have any data to save, log a warning here
        if (gameData == null)
        {
            if (initializeDataIfNull){
                printMsg("No save data found, creating a new game");
                NewGame();
            }
            else {
                Debug.LogWarning("No data was found. A New Game needs to be started before data can be saved.");
                return;
            }
        }


        // load in new objects from scene to be saved
        dataPersistenceObjects = FindAllDataPersistenceObjects();
        gameData.savedGameManagerData.Clear();
        gameData.savedScrewPanelData.Clear();

        // pass the data to other scripts so they can update it
        foreach (var dataPersistenceObj in dataPersistenceObjects)
        {
            dataPersistenceObj.SaveData(gameData);
        }

        //printMsg("Game Saved");

        // timestamp the data so we know when it was last saved
        gameData.lastUpdated = DateTime.Now.ToBinary();

        // save that data to a file using data handler
        dataHandler.Save(gameData, selectedProfileId);
    }


    private List FindAllDataPersistenceObjects()
    {
        // FindObjectsofType takes in an optional boolean to include inactive gameobjects
        // must extend from monobehavior to function to search completely for all objects in IEnumerable
        var dataPersistenceObjects = FindObjectsOfType(true)
            .OfType();

        return new List(dataPersistenceObjects);
    }

    public bool HasGameData()
    {
        return gameData != null;
    }

    public Dictionary GetAllProfilesGameData()
    {
        return dataHandler.LoadAllProfiles();
    }

    private IEnumerator AutoSave()
    {
        while (true)
        {
            yield return new WaitForSeconds(autoSaveTimeSeconds);
            SaveGame();
            printMsg("Auto Saved Game");
        }
    }

    public void printMsg(string msg){
        if (!printDebugs) return;
        Debug.Log(msg);
    }
}

Procedural Obstacle Generation

I developed a procedural object spawner using Poisson Disk Sampling.

CODE SNIPPET - Procedural Fire Spawning

The following code demonstrates the algorithm used to spawn game objects in the game scene


public class FireSpawner : MonoBehaviour
{

    [Header("Fire Prefabs to use")]
    public List firePrefabs;
    [Header("Tuning")]
    public Vector2 zone = Vector2.one;
    public float spaceBetweenFires = 1;
    public float prefabScale = 1;
    private int k = 2;
    private List samples;

    public List StartFire()
    {
        List fireObjects = new List();
        samples = Poisson.GeneratePoint(spaceBetweenFires, zone, k);
        if(samples != null)
        {
            int index;
            foreach(Vector2 sample in samples)
            {

                //instantiate fire
                index = Random.Range(0, firePrefabs.Count);
                GameObject fire = Instantiate(firePrefabs[index], new Vector3(sample.x, 0, sample.y)+transform.position, Quaternion.identity)as GameObject;
                Assert.IsNotNull(fire.GetComponent());
                fire.transform.Rotate(0, Random.Range(0, 360), 0);
                fire.transform.localScale = Vector3.one * prefabScale;
                //add fire to fire list
                fireObjects.Add(fire);
            }
        }
        // if by chance no fires are added to the scene in each sample (i.e count = 0) spawn a fire in the middle of the hub
        if (fireObjects.Count <= 0)
        {
            // instantiate fire in middle of fire spawner zone
            GameObject fire = Instantiate(firePrefabs[0], new Vector3(zone.x/2, 0, zone.y/2)+transform.position, Quaternion.identity)as GameObject;
            fireObjects.Add(fire);
        }

        if (fireObjects.Count > 0) return fireObjects;
        else return null;
    }

    private void OnDrawGizmos()
    {
        Gizmos.color = Color.yellow;
        Gizmos.DrawWireCube((new Vector3(zone.x, 0, zone.y) / 2)+transform.position, new Vector3(zone.x, 0, zone.y));

    }

}
                        

The following code is part of procedural algortihm itself:


    public static List GeneratePoint(float radius, Vector2 grid_size, int numSamplesBeforeRejection  = 30)
    {
        // find the size of a cell's square
        float cell_size = radius / Mathf.Sqrt(2);

        // number of times the cell size fits into sample region size, for each cell
        // grid will tell us for each cell, what the index is of each point, (0 means no point, 1 has index 0)
        // to get the number of columns divide the width / cell_size and rows
        int[,] grid = new int[Mathf.CeilToInt(grid_size.x / cell_size), Mathf.CeilToInt(grid_size.y / cell_size)];

        // create new vectors of sample candidate points
        List samples = new List();
        List spawn_samples = new List();

        // create spawn point list
        spawn_samples.Add(grid_size / 2);
        // while spawn point list is not empty
        while (spawn_samples.Count > 0)
        {
            int index = Random.Range(0, spawn_samples.Count);
            Vector2 current_spawn_sample = spawn_samples[index];
            bool rejected_sample = true;
            for (int i = 0; i < numSamplesBeforeRejection; i++)
            {
                // angle of candidate point
                float angle_offset = Random.value * Mathf.PI * 2;
                //rotate a vector at a given angle
                float x = Mathf.Sin(angle_offset);
                float y = Mathf.Cos(angle_offset);

                Vector2 offset_direction = new Vector2(x, y);

                // new magnitude
                // radius is min so that candidate is spawned outside spawn center
                float new_magnitude = Random.Range(radius, 2 * radius);
                offset_direction *= new_magnitude;

                // assign info to sample
                Vector2 sample = current_spawn_sample + offset_direction;
                if (is_valid(samples, grid, sample, grid_size, radius, cell_size))
                {
                    // add sample to point list
                    samples.Add(sample);
                    // add sample as new spawn point
                    spawn_samples.Add(sample);
                    // record which cell the sample point ends up in
                    grid[(int)(sample.x / cell_size), (int)(sample.y / cell_size)] = samples.Count;
                    rejected_sample = false;
                    break;
                }
            }

            // if not accepted remove from spawn point list
            if (rejected_sample)
            {
                spawn_samples.RemoveAt(index);
            }
        }
        return samples;
    }

Localization
Player Movement
Cinematics
Narrative Design

I co-wrote a linear narrative with a branching final ethical dilemma following a cute, solitary robot in an indie scifi puzzle exploration game. I wrote the central AI dialogue, character bios, audio logs, narration, and an overarching plot to build up an ethical choice for the player. Details about this world are revealed through the AI's dialogue with the player and the crew member audio logs the player collects.

Narrative Vision

I focused on bringing the playable character —a reptilian robot— to life. Working with the game director, I built a world around an alien reptilian species.

Narrative Design Documentation

Beatboard

AI Core Script

Audio Logs


Full Team Technical Design Document

Executive Summary