All projects
Project 04 · Unity · C#

Scapegoat

Role: Save & Progression Systems

Scapegoat is a fast-paced hack-and-slash game built around a blood-powered sword that allows the player to sacrifice health for stronger attacks. I designed and implemented the game’s Save System and Player Upgrade System (Skill Tree).

Scapegoat key art: a chained sword with a living eye, standing among ruined pillars under a red sky

Project Overview

Project Type
Hack and Slash Action Game
Role
Save & Progression Systems
Team Size
14 people
Duration
4 Weeks
Engine
Unity
Language
C#

Upgrade / Skill Tree

Goal. Data-driven progression that designers can author without code changes. All stat modifiers are resolved in one aggregation pass for clarity and balance tuning.

Design Summary

  • SkillNode (ScriptableObject): Name, description, icon, stat type, per-level percentage, cost array, GUID, editor position.
  • Custom Editor Window: Create nodes, rename assets, drag to position, connect prerequisites, edit costs and properties.
  • Runtime UI (SkillButton): Shows name, cost, level; disables when unaffordable or maxed; applies upgrade on click.
  • Player Stats Integration: One method recalculates all stats by aggregating unlocked node modifiers.

Data Model

  • SkillNode.skillCost : int[]
  • SkillNode.currentLevel : int
  • SkillNode.skillTypePositive : enum
  • SkillNode.percentageIncrease : float
  • SkillNode.SkillID : string (GUID)
  • Optional authoring fields kept even if gating is disabled: prerequisites, requiredPrerequisitePoints.
Custom Unity editor window showing connected skill nodes
Editing window for the skill tree, allowing for setting up effects, tradeoffs and relationships between skills. (Not the actual skills from the game)

Authoring Workflow

  1. Create a new SkillNode asset in the editor.
  2. Fill in display fields and costs. Costs define the number of levels.
  3. Optionally connect prerequisites in the editor for visual planning.
  4. Designers place nodes visually; code reads them by GUID at runtime.

Runtime Flow

  • UI reads SkillNode data and renders button state.
  • On click: deduct cost, increment currentLevel, request stat recompute.
  • ApplyUpgrades() scans all nodes, sums per-type modifiers, writes final values to the player, and updates bars.
Wide view of the Scapegoat hub world with portals and a pentagram
Hub-World from where the arenas are reached through the portals and upgrades reached from the pentagram.

Key Snippet

// aggregate modifiers
foreach (var n in _skillNodes)
    if (n.currentLevel > 0)
        upgrades[n.skillTypePositive] =
            upgrades.GetValueOrDefault(n.skillTypePositive, 0f)
            + n.percentageIncrease * n.currentLevel;

// apply
switch (type)
{
    case SkillTypes.MaximumHealth:
        _maxHealth = _baseMaxHealth * (1 + upgrades[type]);
        _currentHealth = _maxHealth;
        break;
    case SkillTypes.AttackDamage:
        _attackModifier = _baseAttackModifier + upgrades[type];
        break;
    // other stats similar
}

UI Behavior

  • Button text shows Cost: <current> or Max Level.
  • Interactable only when player currency ≥ current cost and not maxed.
  • After purchase, UI refreshes all buttons to reflect new affordability and caps.
The upgrade menu in the final build
Upgrade menu in final build, with the aspect the designers had time to implement in the time span

Extensibility

  • Add a new stat: extend the enum and the switch in ApplyUpgrades().
  • Add a new skill: create a SkillNode asset and set fields. No code changes.
  • Save/load already covered via GUID-keyed entries in GameData.

Edge Cases Handled

  • Costs exhausted → level locks at max and displays “Max Level”.
  • Missing designer data defaults to safe values.
  • Aggregation order is irrelevant; all modifiers are additive in one pass.

What Was Implemented

  • SkillNode ScriptableObject definition and GUID generation.
  • Custom Skill Tree Editor window and node linking UX.
  • SkillButton runtime logic and currency gating.
  • Player stat aggregation in ApplyUpgrades() with immediate HUD updates.

Player Stats Integration

ApplyUpgrades() is the single aggregation point. It scans all SkillNodes, sums per-type modifiers, then writes final values to the player. Complexity is O(n) in node count and order-independent.

public void ApplyUpgrades()
{
    var upgrades = new Dictionary<SkillTypes, float>();
    foreach (var node in _skillNodes)
        if (node.currentLevel > 0)
            upgrades[node.skillTypePositive] =
                upgrades.GetValueOrDefault(node.skillTypePositive, 0f)
                + node.percentageIncrease * node.currentLevel;

    foreach (var type in upgrades.Keys)
    {
        switch (type)
        {
            case SkillTypes.AttackDamage:
                _attackModifier = _baseAttackModifier + upgrades[type]; break;
            case SkillTypes.MaximumHealth:
                _maxHealth = _baseMaxHealth * (1 + upgrades[type]);
                _currentHealth = _maxHealth; break;
            case SkillTypes.BloodlettingRegenerationRate:
                _bloodlettingRegenerationRate = _baseBloodlettingRegenerationRate + upgrades[type]; break;
            case SkillTypes.HealthCostOfUsingTheSword:
                _healthCostOfUsingTheSword = _baseHealthCostOfUsingTheSword - upgrades[type]; break;
            case SkillTypes.BloodlettingGainPerAttack:
                _bloodlettingGainPerAttack = _baseBloodlettingGainPerAttack + upgrades[type]; break;
        }
    }
}

Notes:

  • New stats require adding one enum value and one switch case.
  • Negative modifiers are trivial to re-enable if design needs trade-offs.
Scapegoat combat: the player surrounded by enemies
Active combat

Save/Load Architecture

Goal. Create a persistence system that any feature could use without requiring changes to the core code. Saves needed to be human-readable for debugging, but still allowed a simple obfuscation pass if desired.

Key Contributions

Player Upgrade / Skill Tree System

  • Built a skill-tree progression system using ScriptableObjects to define skill nodes and stat modifications.
  • Developed a custom Unity Editor tool to visually create, position, and connect skill nodes.
  • Integrated upgrades with the player’s stat calculation to dynamically adjust health, damage, regeneration, and resource costs.

Save System

  • Created a modular save/load architecture using an ISave interface so new systems can be added without modifying the SaveManager.
  • Implemented a FileDataHandler to serialize GameData into JSON, with optional XOR-based encryption.
  • Ensured persistence of currency, skill progress, and other player-related data across sessions.

Design Summary

  • ISave interface: Any component that implements LoadData(GameData) and SaveData(ref GameData) is automatically included in the save/load process. This allows features to opt-in without modifying any central system.
  • SaveManager. Singleton that, at startup it finds all ISave components in the scene (active and inactive) and coordinates Save, Load, and NewGame. It acts as the single entry point for all persistence.
  • FileDataHandler. Serializes the GameData object into JSON and writes it to disk, with an optional XOR-based encryption step. Creates directories if needed and handles read/write safety.
  • GameData. A flat data container. Uses SerializableDictionary for storing state keyed by unique IDs, such as skill levels or world progress flags.
Scapegoat start menu with an option to continue from a save
Start menu before starting the game with option to start from where you left off

Flow

  • Load
    The SaveManager asks FileDataHandler to read saveData.json.
    • If no save exists, it generates a new GameData, saves once, then loads it.
    • After loading, it distributes the data back into all ISave components.
  • Save
    The SaveManager asks every ISave component to write its state into GameData, then FileDataHandler serializes the result to disk.

Extensibility in Practice

The system was designed so others could add persistent features without modifying any of my code. This worked in production: another programmer later added portal unlock state by simply implementing ISave and storing values in GameData. No changes to SaveManager or the file layer were required.

Data Stored

  • bloodExperience — player currency
  • skillTree (string → int) — saved level of each skill, keyed by unique GUID
  • portalsUnlocked (string → bool) — world state added later by another team member

Key Snippet

public void SaveGame()
{
    foreach (ISave s in _saveComponents)
        s.SaveData(ref _gameData);

    _fileDataHandler.Save(_gameData, selectedProfileID);
}

public void LoadGame()
{
    _gameData = _fileDataHandler.Load(selectedProfileID);
    if (_gameData == null) { NewGame(); return; }

    foreach (ISave s in _saveComponents)
        s.LoadData(_gameData);
}

Why It Scales

  • New features only need to implement ISave and store data in GameData.
  • No changes required to SaveManager as the project grows.
  • JSON files can be inspected quickly during development.
  • Optional encryption is available if tampering is a concern.

Skill Tree Persistence

Skill levels are saved by GUID and restored on load, then ApplyUpgrades() recalculates stats.

public void LoadData(GameData data)
{
    LoadAllSkillNodes();
    _skillNodes.ForEach(n => n.currentLevel = 0);

    foreach (var n in _skillNodes)
        if (data.skillTree.TryGetValue(n.SkillID, out int lvl))
            n.currentLevel = lvl;

    ApplyUpgrades();
}

public void SaveData(ref GameData data)
{
    bool newGame = data.skillTree == null || data.skillTree.Count == 0;
    data.skillTree = new SerializableDictionary<string, int>();

    if (_skillNodes.Count == 0) LoadAllSkillNodes();

    foreach (var n in _skillNodes)
        data.skillTree.Add(n.SkillID, newGame ? 0 : n.currentLevel);
}
Scapegoat: a blood-soaked sword swing cutting through a crowd of hooded enemies