Titanium Core

SOL SKATT

PROGRAMMING / AUDIO / DESIGN

Platform PC

Role Gameplay and Audio

Engine Unity

Language C#

Development Time 7 weeks

Team Size 12

Titanium Core

Titanium Core is a Grid Based Tactical RPG created during a 7 week school project. 


My main role as a programmer had me focusing on various gameplay elements such as the input handling, character and enemy data structures and the player camera logic. I was also responsible for the various audio aspects of the game such as sound design, mixing, implementation and creating an audio system that facilitated an efficient workflow.

PlayerInput/ActionState

Most of the gameplay logic is directed from the PlayerInputState. While being a fairly large system to build in a short amount of time, we had the benefit of creating a game where we knew all possible outcomes at any given point in time. This made it possible for us to map out a tree of all possible game states and their transitions before writing a single piece of code. It turned into a very robust system that needed little iteration thanks to a solid foundation and good planning.


Depending on the player input, one of several actions will be generated. The action is then passed into the ActionState where it gets evaluated and the corresponding Enter and Update methods are called. The rendering is decoupled from the game logic which made this an efficient way to pass the necessary information to the visual system for handling what animations to play and for how long.


public class PlayerInputState : InputState
{
    public static Action OnCharacterSelected;
    public static Action OnNewMoveAction;
    public static Action OnCharactersAssigned;
    public static Action OnPassAction;
    public static Action OnNewCharacter;
    public static Action OnTileSelection;
    public static Action OnMovementStateEnter;
    public static Action OnFail;
    private Action OnClearHover;
    private Action OnClearHoverVfx;
    private Action _interrupt;

    private ActionState _actionState;
    private MoveAction _moveAction;
    private ShootAction _shootAction;
    private GameAction _bufferedAction;
    private Character _hoveredCharacter;
    private int _selectedCharacterIndex;
    
    [Header("Hover Outline")]
    private static readonly int AseOutlineWidth = Shader.PropertyToID("_ASEOutlineWidth");
    [SerializeField] private float _outlineHoverValue = 0.2f;
    [SerializeField] private float _defaultOutlineValue = 0.01f;
    [SerializeField] private GameObject _hoverVfx;
    
    public override void Initialize(object owner)
    {
        base.Initialize(owner);
        _actionState = GetState();
        UIManager.OnAttackClicked += Attack;
        UIManager.OnAbilityClicked += Ability;
        UIManager.OnDefenseClicked += DefenseProtocol;
    }

    public override void Enter()
    {
        _interrupt = null;
        _bufferedAction = null;
        if (Characters.Count == 0)
        {
            GameManager.Lose();
            return;
        }

        if (GetState().GetCharacters(1).Count == 0)
        {
            GameManager.Win();
            return;
        }
        
        if (Characters.All(c => c.Data.RemainingActions == 0))
        { 
            OnCharacterSelected?.Invoke(null);
            GameManager.SwitchTeams();
            return;
        }
        if (SelectedCharacter.Data.RemainingActions == 0)
        {
            for(int i = 0; i < Characters.Count; i++)
            {
                if (Characters[i].Data.RemainingActions == 0)
                    continue;

                SelectedCharacter = Characters[i];
                _selectedCharacterIndex = i;
                OnNewCharacter?.Invoke(SelectedCharacter);
            }
        }
        OnCharacterSelected?.Invoke(SelectedCharacter);
        OnMovementStateEnter?.Invoke(SelectedCharacter);
    }
    public override void StateUpdate()
    {
        if (_bufferedAction != null)
        {
            _actionState.CurrentAction = _bufferedAction;
            OnCharacterSelected?.Invoke(null);
            TransitionTo();
            return;
        }

        if (_interrupt != null)
        {
            _interrupt.Invoke();
            _interrupt = null;
            return;
        }

        if(Input.GetButtonDown("ShootAction"))
        {
            ToAttackState(null);
            return;
        }

        (bool hit, Vector2Int cell) = GridManager.RaycastGrid();    
        
        Character c = GameManager.Instance.GetCharacterAtPosition(cell, 0);
        if(c == null)
            c = GameManager.Instance.GetCharacterAtPosition(cell, 1);
        SetHoveringOutline(c);
        
        if (hit && GridManager.Instance.GetCell(cell).Type == 0)
        {
            VisualHelper.HighlightType type = VisualHelper.HighlightType.Move;
            if (c != null && c.TeamId == 1) type = VisualHelper.HighlightType.Target;
            VisualHelper.SetHighlightCells(new List {cell}, type);
            OnTileSelection?.Invoke(GridManager.Instance.GetCell(cell));
        }
        else
            OnTileSelection?.Invoke(null);
        
        if (GetState().SwitchWeapons())
            return;
        if (CycleCharacters())
            return;
        if (PerformMovementAction())
            return;
        if (PerformShootAction())
            return;
        if (PerformAbilityAction())
            return;
        if (PerformPassAction())
            return;
    }

    private bool PerformAbilityAction()
    {
        if (!Input.GetButtonDown("AbilityAction"))
            return false;

        Ability();
        return true;
    }

    private bool CycleCharacters()
    {
        if(Input.GetButtonDown("Cycle"))
        {
            Cycle();
            return true;
        }

        if (!Input.GetButtonDown("LeftClick"))
            return false;

        (bool hit, Vector2Int gridPos) = GridManager.RaycastGrid();
        if (!hit)
            return false;

        Character character = GameManager.GetCharacterAtPosition(gridPos, 0);
        if (character == null || character == SelectedCharacter)
            return false;

        _selectedCharacterIndex = Characters.IndexOf(character);
        SelectedCharacter = character;
        OnClearHover?.Invoke();
        OnClearHover = null;
        OnClearHoverVfx?.Invoke();
        OnClearHoverVfx = null;
        OnCharacterSelected?.Invoke(SelectedCharacter);
        return true;
    }

    public override void AddCharacters(List characters)
    {
        base.AddCharacters(characters);
        OnCharacterSelected?.Invoke(SelectedCharacter);
        OnCharactersAssigned?.Invoke(characters.ToArray());
    }
    private bool PerformMovementAction()
    {
        if (SelectedCharacter.Data.RemainingActions == 0)
        {
            if(Input.GetButtonDown("LeftClick")) OnFail?.Invoke();
            return false;
        }

        (bool hit, Vector2Int gridPos) = GridManager.RaycastGrid();
        if (!hit || gridPos == SelectedCharacter.Data.Position)
        {
            OnNewMoveAction?.Invoke(null);
            if(Input.GetButtonDown("LeftClick")) OnFail?.Invoke();
            return false;
        }

        List  list = GridManager.Instance.AStar(SelectedCharacter.Data.Cell, GridManager.Instance.GetCell(gridPos));
        if (list == null || list.Count > SelectedCharacter.Data.RemainingSteps + 1)
        {
            if (list != null && list.Count > SelectedCharacter.Data.RemainingSteps + 1)
            {
                VisualHelper.SetHighlightCells(new List {gridPos}, VisualHelper.HighlightType.OutOfMovementRange);
                if(Input.GetButtonDown("LeftClick"))
                    OnFail?.Invoke();
            }
            OnNewMoveAction?.Invoke(null);
            return false;
        }

        _moveAction = new MoveAction(SelectedCharacter, list.Select(x => x.Position).ToList());
        OnNewMoveAction?.Invoke(_moveAction);

        if (_moveAction.Steps > SelectedCharacter.Class.MaxStepsPerAction)
            VisualHelper.SetHighlightCells(new List {gridPos}, VisualHelper.HighlightType.Sprint);
        
        if (!Input.GetButtonDown("LeftClick"))
            return false;

        _actionState.CurrentAction = _moveAction;
        OnCharacterSelected?.Invoke(null);
        TransitionTo();

        return true;
    }
    private bool PerformShootAction()
    {
        if (!Input.GetButtonDown("LeftClick") && !Input.GetButtonDown("RightClick") || SelectedCharacter.Data.RemainingActions == 0)
            return false;

        (bool hit, Vector2Int gridPos) = GridManager.RaycastGrid();
        if (!hit)
            return false;

        Character newTarget = GameManager.GetCharacterAtPosition(gridPos, 1);
        if (newTarget == null)
            return false;

        ShootAction shootAction = new ShootAction(SelectedCharacter, newTarget, SelectedCharacter.Data.CurrentWeapon);
        ToAttackState(shootAction);
        
        return true;
    }

    private void ToAttackState(ShootAction action)
    {
        PlayerAttackState attackState = GetState();
        attackState.ShootAction = action;
        attackState.SelectedCharacter = SelectedCharacter;
        TransitionTo();
    }
    private bool PerformPassAction()
    {
        if (!Input.GetButtonDown("Pass") || SelectedCharacter.Data.RemainingActions == 0 || StateMachine.CurrentState != this )
            return false;

        _actionState.CurrentAction = new PassAction(SelectedCharacter);
        OnCharacterSelected?.Invoke(null);
        OnPassAction?.Invoke(SelectedCharacter);
        TransitionTo();

        return true;
    }
    public override void Exit()
    {
        VisualHelper.SetHighlightCells(null);
        _bufferedAction = null;
        OnNewMoveAction?.Invoke(null);
        OnTileSelection?.Invoke(null);
        OnClearHover?.Invoke();
        OnClearHover = null;
        _hoveredCharacter = null;
    }

    private void Attack()
    {
        if (SelectedCharacter.Data.RemainingActions == 0)
            return;

        _interrupt = () =>
        {
            PlayerAttackState attackState = GetState();
            attackState.ShootAction = null;
            attackState.SelectedCharacter = SelectedCharacter;
            TransitionTo();
        };
    }
    private void Ability()
    {
        if (SelectedCharacter.Data.RemainingActions == 0)
            return;
        if (SelectedCharacter == null || StateMachine.CurrentState.GetType() != GetType() || SelectedCharacter.Data.RemainingAbilityUses <= 0)
            return;

        _interrupt = () => { };
        if (SelectedCharacter.Class.ClassAbility.GetType() == typeof(SniperAbility))
            TransitionTo();
        else if (SelectedCharacter.Class.ClassAbility.GetType() == typeof(GrenadierAbility))
            TransitionTo();
        else if (SelectedCharacter.Class.ClassAbility.GetType() == typeof(RangerAbility))
            TransitionTo();
    }
    private void DefenseProtocol()
    {
        if (SelectedCharacter == null || SelectedCharacter.Data.RemainingActions == 0 || StateMachine.CurrentState != this)
            return;

        _actionState.CurrentAction = new PassAction(SelectedCharacter);
        OnCharacterSelected?.Invoke(null);
        OnPassAction?.Invoke(SelectedCharacter);
        TransitionTo();
    }
    private void Cycle()
    {
        _selectedCharacterIndex = (_selectedCharacterIndex + 1) % Characters.Count;
        SelectedCharacter = Characters[_selectedCharacterIndex];
        OnClearHover?.Invoke();
        OnClearHover = null;
        OnCharacterSelected?.Invoke(SelectedCharacter);
        if(SelectedCharacter.Data.RemainingActions == 0) Cycle();    //TODO: dont like this recursive call
        OnNewCharacter?.Invoke(SelectedCharacter);
    }

    private void SetHoveringOutline(Character character)
    {
        OnClearHover?.Invoke();
        OnClearHover = null;

        if (character == null)
        {
            OnClearHoverVfx?.Invoke();
            OnClearHoverVfx = null;
            _hoveredCharacter = null;
        }
        
        if (character != null && character != SelectedCharacter)
        {
            if (character.TeamId == 0 && (_hoveredCharacter == null || _hoveredCharacter != character))
            {
                OnClearHoverVfx?.Invoke();
                OnClearHover = null;
                GameObject instance = Instantiate(_hoverVfx, character.Data.WorldRepresentation.transform.position, Quaternion.identity);
                OnClearHoverVfx += () => Destroy(instance);
                _hoveredCharacter = character;
            }
            
            SkinnedMeshRenderer _renderer = 
                character.Data.WorldRepresentation.GetComponentInChildren();

            if(character.Behaviour == Character.BehaviourType.Ranged)
                _renderer.materials[1].SetFloat(AseOutlineWidth, _outlineHoverValue);
            else
                _renderer.material.SetFloat(AseOutlineWidth, _outlineHoverValue);

            OnClearHover += () =>
            {
                if(character.Behaviour == Character.BehaviourType.Ranged)
                    _renderer.materials[1].SetFloat(AseOutlineWidth, _defaultOutlineValue);
                else
                    _renderer.material.SetFloat(AseOutlineWidth, _defaultOutlineValue);
            };
        }   
    }
}
    


public class ActionState : TurnManagerState
{
    public static Action OnActionPerformed;
    public GameAction CurrentAction;
    [SerializeField] private float _fastTimeScale = 2.0f;
    [SerializeField] private float _defenseWaitTime = 0.5f;
    private float _visualUpdateTime;
    private float _timer;
    private readonly Dictionary> _enterMethods = new Dictionary>();
    private readonly Dictionary _updateMethods = new Dictionary();
    private float _waitTime;
    public float Progress => Mathf.Clamp01(_timer/Mathf.Max(_visualUpdateTime, 0.001f));

    public static Action OnMoveActionStart;
    public static Action OnShootActionStart;
    public static Action OnMoveActionFinished;
    public static Action OnAbilityActionStart;

    public override void Initialize(object owner)
    {
        base.Initialize(owner);

        _enterMethods.Add(typeof(MoveAction), () => VisualMovementSystem.CalculateTime(CurrentAction as MoveAction));
        _enterMethods.Add(typeof(ShootAction), () => VisualShootingSystem.CalculateTime(CurrentAction as ShootAction));
        _enterMethods.Add(typeof(PassAction), () =>
        {
            CurrentAction.Character.Data.SpawnDefenseParticles();
            return _defenseWaitTime;
        });
        _enterMethods.Add(typeof(AbilityAction), () => (CurrentAction as AbilityAction).VisualTime);

        _updateMethods.Add(typeof(MoveAction), () => VisualMovementSystem.UpdateMovementVisuals(CurrentAction as MoveAction));
        _updateMethods.Add(typeof(ShootAction), () => VisualShootingSystem.Update(CurrentAction as ShootAction, _timer));
        _updateMethods.Add(typeof(AbilityAction), () => (CurrentAction as AbilityAction).VisualUpdate.Invoke(_timer));
    }
    public override void Enter()
    {
        _visualUpdateTime = _enterMethods.ContainsKey(CurrentAction.GetType()) ? _enterMethods[CurrentAction.GetType()]() : 0.0f;
        _timer = 0.0f;
        if (CurrentAction.Character.TeamId == 0)
            OnActionPerformed?.Invoke(CurrentAction);

        if (CurrentAction is MoveAction)
            OnMoveActionStart?.Invoke();
        else if (CurrentAction is ShootAction)
            OnShootActionStart?.Invoke();
        else if (CurrentAction is AbilityAction action)
            OnAbilityActionStart?.Invoke(action);
    }
    public override void StateUpdate()
    {
        if (Input.GetButton("TimeDilation"))
            Time.timeScale = _fastTimeScale;
        else
            Time.timeScale = 1.0f;
    
        _timer += Time.deltaTime;
        if (_timer < _visualUpdateTime)
        {
            if (_updateMethods.ContainsKey(CurrentAction.GetType()))
                _updateMethods[CurrentAction.GetType()].Invoke();

            return;
        }

        if (CurrentAction.IsDone)
        {
            if (_timer < _visualUpdateTime + _waitTime)
                return;

            GameManager.GoToInputState();
            OnMoveActionFinished?.Invoke();
            return;
        }
        _waitTime = CurrentAction.Execute();

    }
    public override void Exit()
    {
        Time.timeScale = 1.0f;
    }
}
    

AudioPlayer/AudioObject

The audio system facilitates an efficient workflow where the sound designer creates an AudioObject, adds sound clips to it and then binds the object to the corresponding function call
in the AudioManager. This way the sound designer never has to interact directly with the complex state system and can focus their time on perfecting the sounds and mix in the game.


public class AudioObject : ScriptableObject
{
    [Multiline] public string Description;
    public AudioClip[] Clips;
    public AudioMixerGroup MixerGroup;
    public MinMaxFloat Volume;
    public MinMaxFloat Pitch;
    public bool RandomStartTime;
    public bool Loop;
    
    public AudioClip RandomClip => Clips[Random.Range(0, Clips.Length)];
    
    public void PlaySound()
    {
        AudioManager.PlaySound(name);
    }
}
    


public static class AudioPlayer
{
    private static readonly Dictionary AudioObjects = new Dictionary();
    private static AudioManagerHelper CoroutineSlave;
    private static readonly GameObject AudioSourcePrefab;
    private static readonly Dictionary _persistentSounds = new Dictionary();
    
    static AudioPlayer()
    {
        AudioObject[] objects = Resources.LoadAll("");
        foreach (AudioObject ao in objects)
        {
            if(AudioObjects.ContainsKey(ao.name))
                Debug.LogWarning("Multiple audioObjects with the same name: " + ao.name);
            else
                AudioObjects.Add(ao.name, ao);
        }

        AudioSourcePrefab = Resources.Load("AudioSourcePrefab") as GameObject;
    }

    public static void Init()
    {
        if(AudioObjects.Count == 0)
            Debug.Log("loading resources");
    }
    public static AudioRemote PlaySound(string audioObjectName)
    {
        if (!AudioObjects.ContainsKey(audioObjectName))
        {
            Debug.LogError($"AudioObject with name {audioObjectName} does not exist");
            return null;
        }

        AudioObject ao = AudioObjects[audioObjectName];
        AudioSource source = ObjectPool.Instantiate(AudioSourcePrefab).GetComponent();

        if (ao.Clips.Length == 0)
        {
            Debug.LogError("No clips in AudioObject: " + audioObjectName);
            return null;
        }
        source.clip = ao.RandomClip;
        source.outputAudioMixerGroup = ao.MixerGroup;
        source.volume = ao.Volume.RandomValue;
        source.pitch = ao.Pitch.RandomValue;
        if (ao.RandomStartTime)
            source.time = Random.Range(0.0f, source.clip.length);
        source.loop = ao.Loop;    
        source.Play();
        
        if(CoroutineSlave == null)
            CoroutineSlave = new GameObject("AudioManagerHelper").AddComponent();

        if(!ao.Loop)
            CoroutineSlave.DestroyWhenDone(source);
        
        return new AudioRemote(source);
    }
    public class AudioRemote
    {
        private readonly AudioSource _source;
        
        public AudioRemote(AudioSource source)
        {
            _source = source;
        }
        public void Pause()
        {
            _source.Pause();
        }
        public void Unpause()
        {
            _source.UnPause();
        }
        public void Destroy()
        {
            if(_source != null)
                ObjectPool.Destroy(_source.gameObject);   
        }       
    }
    private class AudioManagerHelper : MonoBehaviour
    {
        public void DestroyWhenDone(AudioSource source)
        {
            StartCoroutine(Coroutine());
            IEnumerator Coroutine()
            {
                while (source.isPlaying)
                    yield return null;
                
                ObjectPool.Destroy(source.gameObject);
            }
        }
    }
}
    


        ///////////////////
        // Weapon SOUNDS //
        ///////////////////

        TutorialState.OnFinished += () =>
        {
            PlaySfx(_playerTeamChangeSound);
        };
        BulletManager.OnFire += shootAction =>
        {
            if (shootAction.Weapon.Type == Weapon.WeaponType.Sniper)
                PlaySfx(_sniperSound);
        };
        BulletManager.OnFire += shootAction =>
        {
            if (shootAction.Weapon.Type == Weapon.WeaponType.ShotGun)
                PlaySfx(_shotgunSound);
        };
        BulletManager.OnFire += shootAction =>
        {
            if (shootAction.Weapon.Type == Weapon.WeaponType.GrenadeLauncher)
                PlaySfx(_grenadeLauncherSound);
        };
        BulletManager.OnHasHit += shootAction =>
        {
            if (shootAction.Weapon.Type == Weapon.WeaponType.GrenadeLauncher)
                PlaySfx(_grenadeLauncherHitSound);
        };
        BulletManager.OnFire += shootAction =>
        {
            if (shootAction.Weapon.Type == Weapon.WeaponType.AssaultRifle)
                PlaySfx(_assaultRifleSound);
        };
        BulletManager.OnFire += shootAction =>
        {
            if (shootAction.Weapon.Type == Weapon.WeaponType.Pistol)
                PlaySfx(_pistolSound);
        };
        BulletManager.OnFire += shootAction =>
        {
            if (shootAction.Weapon.Type == Weapon.WeaponType.LaserRifle)
                PlaySfx(_laserRifleSound);
        };
        BulletManager.OnFire += shootAction =>
        {
            if (shootAction.Weapon.Type == Weapon.WeaponType.EnemyRanged)
                PlaySfx(_enemyRangedSound);
        };
        BulletManager.OnFire += shootAction =>
        {
            if (shootAction.Weapon.Type == Weapon.WeaponType.EnemyMelee)
                PlaySfx(_enemyMeleeSound);
        };
    


    private void SetVolume (float volume, string type)
    {
        if (_master == null) return;
        volume = Mathf.Clamp(volume, 0.0001f, 1);
        PlayerPrefs.SetFloat(type, volume);
        Debug.Log(type + ": " + Mathf.Log10(volume) * 20 + " dB delta");
        float decibels = Mathf.Log10(volume) * 20;
        _master.SetFloat(type, decibels);
    }

    private void PlaySfx(AudioObject audioObject)
    {
        if (audioObject == null) return;
        AudioManager.AudioRemote remote = AudioManager.PlaySound(audioObject.name);
    }
    private void PlayLoopingSfx(AudioObject audioObject)
    {
        if (audioObject == null) return;
        AudioManager.AudioRemote remote = AudioManager.PlaySound(audioObject.name);
        if(_remotes.ContainsKey(audioObject.name))
        {
            _remotes[audioObject.name].Destroy();
            _remotes.Remove(audioObject.name);
        }
        _remotes.Add(audioObject.name, remote);
    }
    private void StopSfx(AudioObject audioObject)
    {
        if (audioObject == null) return;
        if (_remotes.ContainsKey(audioObject.name))
        {
            _remotes[audioObject.name].Destroy();
            _remotes.Remove(audioObject.name);
        }
    }
    

Lessons learned

Working in a team with other experienced programmers, this was the first time I was able to focus on a top-down approach to building systems and structuring code. A lot of time went into planning and estimating development time to better evaluate risk/return of features to implement.


All in all this was quite a technically demanding game to make where I learned a lot about structuring and managing systems in a larger project while also taking on a support role, creating and iterating on tools for other people on the team.