Summary
By Tooth & Tail is a 3D top down, grid-based stealth game, where I worked on gameplay and system programming. I did the mobile port, making sure it had the same playability as the PC version. I learned from friction in the team, keeping communication going in the team and got better at estimating time for features.
Project Info
Team Size: 15
Project Duration: 4 weeks
Engine: Unity
Roles: Gameplay/System programmer
Other Tools: Perforce, Miro, Jira
Introduction
By Tooth & Tail is a 3D top down, grid-based stealth game where the player is playing as a failed experiment rat that is trying to find the dagger plaid. Inspired by the pokemon dungeons movement, in our game whenever the player moves or does an action that counts as a turn action, makes the enemy make a turn action. There are interactables used for distracting enemies, making them move in its direction, and hiding spots used for stealth.
I worked primarily on:
Turn based system - choosing what counts as a turn action and how the game will transition turns from the player to npc in the world.
Interactables - any items that the player could interact with was my responsibility to handle, items that would affect enemies and player pickups.
UI implementation - helping to implement UI for the PC and mobile port.
Porting the game to mobile - making sure controls and gameplay worked the same for both PC and mobile versions, also handling performance issues that could arise when working with weaker hardware.
Turn based system
Every time the player makes an action input is counted as a turn, action inputs are; moving to a tile and interacting with a distractible object. When the player has made its action it switches to the enemies turn. After all enemies have done their actions the system repeats itself and it goes back to the player's turn.
Some struggles I had when making this system was because of a bug. The bug made it possible to move twice in one turn when the player should only be able to move ones. The cause of the bug was most likely by how inputs were handled. When we received an input we took its value it held and then broadcast that value using an event. Because of this set up, the player could spam inputs broadcasting multiple inputs before the turn system could switch to the enemies turn. The solution was adding an execution phase that gets activated after the enemies had decided their turn.
using System;
using System.Collections;
using System.Threading.Tasks;
using UnityEngine;
public class TurnManager : MonoBehaviour
{
// Todo
// Have a delay after all have taken a turn for animations
public enum Turn
{
Player,
Enemy,
ExecuteActions
}
public static TurnManager Instance; //TODO : create foundation master
public event Action OnPlayerAction; //TODO : we can use Basic signal injection
public event Action OnAllEnemiesActed;
public event Action OnExecutedActions;
[NonSerialized] public Turn currentTurn;
[Tooltip("If the the player makes a move while a action is happening the games moves forward, skipping animations")]
[SerializeField] private int m_delayInMillisecond = 200;
private bool m_isWorking;
private void Awake()
{
if (Instance == null)
{
Instance = this;
DontDestroyOnLoad(Instance);
}
else
{
Destroy(gameObject);
}
}
private async void ExecuteActionsDelay()
{
OnExecutedActions?.Invoke();
await Task.Delay(m_delayInMillisecond);
if(!m_isWorking)
StartCoroutine(WaitForSwitch());
}
public void ClearTurnState()
{
Debug.Log("[TurnManager] Clearing turn state...");
currentTurn = Turn.Player;
OnPlayerAction = null;
OnAllEnemiesActed = null;
OnExecutedActions = null;
StopAllCoroutines();
CancelInvoke();
}
public void PlayerAction()
{
if (GameManger.Instance.CurrentGameState == GameManger.GameState.GameRunning)
{
if (currentTurn != Turn.Player) return;
currentTurn = Turn.Enemy;
OnPlayerAction?.Invoke();
EnemiesActed();
}
}
public void EnemiesActed()
{
currentTurn = Turn.ExecuteActions;
OnAllEnemiesActed?.Invoke();
ExecuteActionsDelay();
}
private IEnumerator WaitForSwitch()
{
m_isWorking = true;
yield return new WaitForSeconds((0.3f * EnemyTracker.Instance.CurrentMovesPerTurn()) + (EnemyTracker.Instance.CurrentMovesPerTurn() * 0.1f));
currentTurn = Turn.Player;
m_isWorking = false;
}
}
using System;
using UnityEngine;
public class PlayerTakeTurn : MonoBehaviour
{
// Modify with more action types if needed
public enum PlayerAction
{
NotSet,
Move,
Interact,
Skip
}
[NonSerialized] public PlayerAction CurrentAction;
private PlayerMovement m_playerMovement;
private PlayerInteract m_playerInteract;
private void Start()
{
m_playerMovement = GetComponent<PlayerMovement>(); //TODO : Use serialize
m_playerInteract = GetComponent<PlayerInteract>();
TurnManager.Instance.OnExecutedActions += ExecuteAction;
}
public void PlayerTakeAction(PlayerAction action)
{
CurrentAction = action;
TurnManager.Instance.PlayerAction();
}
public bool IsPlayersTurn()
{
return TurnManager.Instance.currentTurn == TurnManager.Turn.Player;
}
public void ClearActions()
{
CurrentAction = PlayerAction.NotSet;
}
private void SkipTurn()
{
Debug.Log("Skip Turn");
}
// Put whatever function you need to make things work
private void ExecuteAction()
{
switch (CurrentAction)
{
case PlayerAction.NotSet:
break;
case PlayerAction.Move:
m_playerMovement.ExecuteAction();
break;
case PlayerAction.Interact:
m_playerInteract.ExecuteAction();
break;
case PlayerAction.Skip:
SkipTurn();
break;
}
CurrentAction = PlayerAction.NotSet;
}
}
using UnityEngine;
public class EnemyTakeTurn : MonoBehaviour
{
// Modify with more action types if needed
public enum EnemyAction
{
NotSet,
Move,
Interact,
Skip
}
private EnemyManager m_enemyManger;
private void Start()
{
m_enemyManger = GetComponent<EnemyManager>();
TurnManager.Instance.OnPlayerAction += EnemiesTurn;
TurnManager.Instance.OnExecutedActions += ExecuteActions;
}
private void EnemiesTurn()
{
m_enemyManger.EnemiesTurn();
}
private void ExecuteActions()
{
m_enemyManger.ExecuteActions();
}
public void EnemiesTookAction()
{
TurnManager.Instance.EnemiesActed();
}
public bool IsEnemiesTurn()
{
return TurnManager.Instance.currentTurn == TurnManager.Turn.Enemy;
}
}
What I learned
For this project I got the unfortunate experience of having friction in the team to the point where the quality of the game got impacted. From this experience I learned some important things to do and keep in mind when dealing with these kinds of situations. Being calm under undesirable situations is super important, bringing negative emotions does not help the situation, staying calm or even walking away from the situation for a movement can be super important, when later the situation needs to be addressed. Bringing in other neutral people in the conversation when things need to be meditated is very helpful to put things into perspective for both parties and that the conversation stays civil and does not become heated. Keeping self motivation up, wanting to continue working on a project and wanting to fix friction in the team is super important, so work doesn't get hindered by a lack of motivation.
I learned how important communication is especially when working remotely, so communication does not get missed or wrongly interpreted in the team. Being updated on what is happening in the project, on what is getting worked on, what is done and what needs to get done, keeps everyone tuned in on what state the project is in. Jira has been super helpful in this front to keep track of what is happening in the project.
I got better at estimating and planning for deadlines for features, so the team can more easily plan around when my work will be done. If I went over the estimated time I was able to communicate that I need more time and give a new estimate of when a feature would be done.