Blindsided


Made in July 2024 using Unity and C#.

Game Page



Highlights

  • A choice-based narrative-driven game where you interact with characters to progress in the story and uncover a sinister mystery.

  • Gauge the flow of the conversation and choose the correct response to advance.

  • Paying close attention helps make the correct choice.

  • Also features 2 minigames closely tied-in to the story for novelty and variety.


  • Two version of this game were developed for my Master's degree dissertation, where we studied the effects of juiciness in narrative-driven games.

Description

In Blindsided, players will play as Jared, a schoolboy, who seeks to uncover the story behind his father’s disappearance. Jared must befriend strangers, converse skilfully, and display politeness, aggression, tact, and diplomacy at the right time to gather information that will bring the culprits of his father to justice.

To get information out of other characters, the player, while controlling Jared, must make the right dialogue choices to advance the story. In addition, Jared will be involved in a fist fight and a covert operation to break into a digital safe. The player will utilise their gaming skills to achieve these feats in a couple of minigames before finally getting to the truth. But even after finding what he sought, will Jared get closure?

Technical Walkthrough

Dialogue System

  • Being a narrative-driven game, the dialogue system was the most important and the first to be developed.
  • The player's dialogues, which appear as buttons in the game, were implemented as a structure in the code, with the following fields:
    • dialogueId: An enum field to uniquely identify each dialogue.
    • playerStates: A list of enums that describes the player states in which the dialogue is accessible. If the player's current state is not present in this list, then the dialogue button will not appear.
    • dialogueText: The text of the dialogue stored as a string.
    • stateToResponseMap: A Dictionary mapping each state in the playerStates list to an Action. When a dialogue button is pressed, the Action corresponding to the player's state is invoked.
    • dialogueAction: An Action variable to store the action to execute when the dialogue button is pressed.
    [System.Serializable]
    public struct SDialogue
    {
        public EDialogueID dialogueId;
        public List<PlayerStates> playerStates;
        public string dialogueText;
        public Dictionary<PlayerStates, Action> stateToResponseMap;
        public Action dialogueAction;
    };
    
  • The DialogueManager class handles the storage and fetching of dialogues based on ID or player state. Each NPC extends this class and populates its list of dialogues and associated details:
    private void FrndDontUWorkAtLettingsNeutralAction()
    {
        string[] dialogueList = {
            "How do you know that? Have you been stalking me?"
        };
    
        audioController.PlaySound(howDoUKnowVoice);
        uiController.StartDialogues(dialogueList, FRIEND_CHAR, OnDialogueEnd);
    }
    
    private void FrndDontUWorkAtLettingsJobRvldAction()
    {
        string[] dialogueList = {
            "Yeah. How about you, are you working anywhere?"
        };
    
        audioController.PlaySound(yepVoice);
        uiController.StartDialogues(dialogueList, FRIEND_CHAR, () =>
        {
            var playerDialogueList = GetDialogueListFromId(new List<EDialogueID> { EDialogueID.FRAPPLIEDMYSELF, EDialogueID.FRIDONTWORK });
            uiController.DisplayPlayerDialoguePanel(playerDialogueList);
        });
    }
            
    private void PopulateDialogueList()
    {
        SDialogue frndDontUWorkAtLettings = new SDialogue();
        frndDontUWorkAtLettings.dialogueText = "You work at the ABC Lettings, don’t you?";
        frndDontUWorkAtLettings.dialogueId = EDialogueID.FRDONTUWORKATLETTINGS;
        frndDontUWorkAtLettings.playerStates = new List<PlayerStates> { PlayerStates.NEUTRAL, PlayerStates.FRNDJOBREVEALED };
        Dictionary<PlayerStates, Action> frndDontUWorkAtLettingsRespMap = new Dictionary<PlayerStates, Action>();
        frndDontUWorkAtLettingsRespMap[PlayerStates.NEUTRAL] = FrndDontUWorkAtLettingsNeutralAction;
        frndDontUWorkAtLettingsRespMap[PlayerStates.FRNDJOBREVEALED] = FrndDontUWorkAtLettingsJobRvldAction;
        frndDontUWorkAtLettings.stateToResponseMap = frndDontUWorkAtLettingsRespMap;
        dialogueList.Add(frndDontUWorkAtLettings);
    }
    
  • The above snippet defines a dialogue "You work at the ABC Lettings, don’t you?" which can be accessed only if the player's state is either NEUTRAL, or FRNDJOBREVEALED i.e. when the friend character has revealed their job to the player.
  • However, the friend's response will differ for both these states. For the NEUTRAL state, the 'FrndDontUWorkAtLettingsNeutralAction' will be invoked, which will make display the friend's response as "How do you know that? Have you been stalking me?"; whereas for the FRNDJOBREVEALED state, the response will be "Yeah. How about you, are you working anywhere?" as defined in the 'FrndDontUWorkAtLettingsJobRvldAction' above.


  • All other dialogues and their responses for various player states were defined similarly. The Actions invoked for various states can also update the player state themselves, so when the player approaches the NPC again, the dialogue choices displayed will also change accordingly.



  • When the player interacts with an NPC, a list of dialogues based on the player's state is fetched and forwarded to the UI Controller to be displayed:
    public void Interact()
    {
        if (!gameController.CanPlayerMoveOrInteract())
        {
            return;
        }
    
        DialogueManager myDialogueMgr = GetComponent<DialogueManager>();
        if (myDialogueMgr == null)
        {
            return;
        }
    
        NPCMovement myNpcMovement = GetComponent<NPCMovement>();
        if (myNpcMovement != null)
        {
            myNpcMovement.StopWalking();
        }
    
        gameController.DisablePlayerMovement();
        List<SDialogue> dialogues = myDialogueMgr.GetDialogueListBasedOnState();
        uiController.DisplayPlayerDialoguePanel(dialogues);
    }
    
  • The UI Controller calls the Spawn() function of the player dialogue panel, which instantiates each dialogue button using a serialized prefab, and also initialises the button with the dialogue data, so that the correct Action is invoked when the button is clicked:
    public class PlayerDialoguesOuterPanel : MonoBehaviour
    {
        public void Spawn(List<SDialogue> dialogues, UIController uIController)
        {
            instantiatedPlayerDialogueInnerPanel = Instantiate(playerDialogueInnerPanelPrefab, transform, false);
            foreach (SDialogue dialogue in dialogues)
            {
                GameObject newDialogueButton = Instantiate(dialogueButtonPrefab);
                newDialogueButton.GetComponent<PlayerDialogueButton>().Init(dialogue, uIController);
                newDialogueButton.transform.SetParent(instantiatedPlayerDialogueInnerPanel.transform, false);
            }
    
            this.gameObject.SetActive(true);
        }
    }
    
    public class PlayerDialogueButton : MonoBehaviour
    {
        public void Init(SDialogue dialogue, UIController uiController)
        {
            this.dialogue = dialogue;
            this.uiController = uiController;
            buttonText.text = dialogue.dialogueText;
        }
    
        public void OnButtonClicked()
        {
            this.dialogue.dialogueAction.Invoke();
            uiController.HidePlayerDialoguePanel();
        }
    }
    
  • The dialogueAction property of each dialogue is assigned a lambda function by the NPC's Dialogue Manager that invokes the Action based on the player's state. This could not be done in the PlayerDialogueButton class in the previous snippet as it is not aware of the player state, nor should it be since it is just a button and should only be responsible for button-related behaviour (Single Responsiblity Principle).
    public class Friend_DialogueMgr : DialogueManager
    {
      new void Start()
      {
          dialogueList = new List<SDialogue>();
          PopulateDialogueList();
    
          for (int i = 0; i < dialogueList.Count; i++)
          {
              SDialogue currentDialogue = dialogueList[i];
              currentDialogue.dialogueAction = () =>
              {
                  InvokePlayerDialogueAction(currentDialogue.stateToResponseMap);
              };
              dialogueList[i] = currentDialogue;
          }
    
          base.Start();
      }
      
      private void InvokePlayerDialogueAction(Dictionary<PlayerStates, Action> rshipToActionMap)
      {
          if (!rshipToActionMap.ContainsKey(stateWPlayer))
          {
              Debug.LogError("ERROR: No action defined for this dialogue at " + stateWPlayer + " state.");
              return;
          }
          rshipToActionMap[stateWPlayer].Invoke();
      }
    }
    

Dialogue UI Display

  • With so much text to feed to the player, the UI system needed to have a common mechanism to display dialogue regardless of its speaker.
  • The speaker also needed to be identified to correctly display their name and picture on the dialogue panel. An enum and structure were used for this purpose:
    public enum ECharacters
    {
        NARRATOR, LEAD, FRIEND, BULLY, JANITOR, MANAGER
    }
    
    [System.Serializable]
    public struct SSPeakerInfo
    {
        public ECharacters speaker;
        public string speakerName;
        public Sprite speakerImg;
    }
    


  • A single method called 'StartDialogues' was defined which initialised the speaker UI elements, cycled through the dialogue list and invoked the provided action at the end:
    public class UIController : MonoBehaviour
    {
      public void StartDialogues(string[] npcDialogues, ECharacters speaker, Action dialogueEndAction)
      {
          gameController.DisablePlayerMovement();
          DisplayNPCDialoguePanel(speaker);
          StartCoroutine(RunThroughNPCDialogues(npcDialogues, dialogueEndAction));
      }
      
      private void DisplayNPCDialoguePanel(ECharacters speaker)
      {
          if (speaker == ECharacters.NARRATOR)
          {
              npcDialoguePanel.HideNPCSpeakerDetails();
          } else
          {
              SSPeakerInfo speakerInfo = speakerToInfoMap[speaker];
              npcDialoguePanel.SetNPCSpeakerDetails(speakerInfo.speakerName, speakerInfo.speakerImg);
          }
    
          npcDialoguePanel.Spawn();
      }
      
      private IEnumerator RunThroughNPCDialogues(string[] npcDialogues, Action dialogueEndAction)
      {
          for (int i = 0; i < npcDialogues.Length; i++)
          {
              npcDialoguePanel.SetNPCDialogueText(npcDialogues[i]);
              npcDialogueAdvanced = false;
    
              yield return new WaitUntil(() =>
              {
                  return npcDialogueAdvanced;
              });
          }
          HideNPCDialoguePanel();
          if (dialogueEndAction != null)
          {
              dialogueEndAction.Invoke();
          }
      }
    
      //Called when the dialogue panel is clicked
      //This causes the 'RunThroughNPCDialogues' to move to the next dialogue
      public void AdvanceNPCDialogue()
      {
          npcDialogueAdvanced = true;
      }
    }
    
  • All game characters call this function to display their dialogues, resulting in a single point to handle UI dialogue display, such as in the example below:
    private void FrndMaybeICanHelpAction()
    {
        string[] dialogueList = {
            "There’s this big bully, Roger, who’s always picking on my sister.",
            "We’ve complained to the teachers about him but it’s no use.",
            "Do you think you could get him to stop harassing my sis?"
        };
        stateWPlayer = PlayerStates.FRNDBULLYMENTIONED;
    
        uiController.StartDialogues(dialogueList, FRIEND_CHAR, () =>
        {
            var playerDialogueList = GetDialogueListFromId(new List<EDialogueID>
            { EDialogueID.FRWILLTALK2BULLY, EDialogueID.FRWONTTALK2BULLY });
    
            uiController.DisplayPlayerDialoguePanel(playerDialogueList);
        });
    }
    
  • The above function is called when the player offers to help a friend. The 'StartDialogues' function is then called with the friend character as the speaker which initialises the speaker panel with the name and image of the friend. After cycling through the three dialogues provided, the lambda function provided as the dialogueEndAction is called which displays the dialogue buttons pertaining to the two dialogue IDs - 'FRWILLTALK2BULLY' and 'FRWONTTALK2BULLY.'

Interaction

  • Player interaction is programmed based on an Interactor-Interactable relationship, with the Interactable objects implementing an interface and defining its 'Interact' function.
    public void Interact()
    {
        if (!CanPlayerInteract())
        {
            //Dont let player interact with safe until player accepts safe crack mission
            //or if player has already cracked the safe
            return;
        }
    
        if (instantiatedMinigame != null)
        {
            return;
        }
        instantiatedMinigame = Instantiate(safeCodeMinigame);
        instantiatedMinigame.GetComponent<SafeCodePuzzle>().Init(this, audioController, gameController);
        gameController.DisablePlayerMovement();
    }
    
  • The Interactor keeps track of any interactables within its trigger volume and calls its 'Interact' function when the player performs the Interact action:
    void Update()
    {
        if (objectInteractAction.action.WasReleasedThisFrame() && currentInteractable != null
            && !gameController.IsGamePaused())
        {
            currentInteractable.Interact();
        }
    }
    
    private void OnTriggerEnter2D(Collider2D collision)
    {
        if (collision != null)
        {
            IInteractable interactable = collision.gameObject.GetComponent<IInteractable>();
            if (interactable != null)
            {
                currentInteractable = interactable;
                currentInteractable.OnPlayerEnteredToInteract();
            }
        }
    }
    
    private void OnTriggerExit2D(Collider2D collision)
    {
        if (collision != null)
        {
            IInteractable interactable = collision.gameObject.GetComponent<IInteractable>();
            if (interactable != null)
            {
                interactable.OnPlayerExited();
                currentInteractable = null;
            }
        }
    }