Hush.

Lead Systems Engineer | VR Puzzle Game | Unity XR
11 developers | 5 months | December 2021


Description.

In "Hush," you play as an innocent intern looking to pick up an antique book; however, you find Dr. Montgomery's library is not what it seems as the door locks behind you. At the expense of interns' souls, he keeps his youth. You must escape before he quenches his thirst yet again.


Contributions.

As Lead Systems Engineer, I coordinated tasks and communication among the programming team through weekly standups and frequent check-ins. In addition to these responsibilities, I had the privilege to implement key interactive elements, including the following:


  • Haptic-feedback-based Ouija board puzzle [Jump]
  • Drawer & fuse box physics
  • Base Player XR controller
  • Hand animation scripting & pivots
  • Puzzle completions leading to aging and portrait changes
    [Jump]
A dimly lit library with wooden shelves, a candle-lit desk, and red-patterned wallpaper from the video game 'Hush'

Ouija Board.

Description.

The Ouija board provides the means of communication between the deceased interns and the player. As the user gets closer to the next letter, the ghost vibrates the planchette with a greater intensity. Through this haptic feedback, users are guided to the correct letters, which are written on the chalkboard.

Challenges.

As the team was utilizing Unity XR's Grab Interactable script, the planchette's interactions posed a challenge because this script removes positional constraints on held objects. To avoid the removal of these necessary constraints, I hid the Grab Interactable object's mesh and replaced it with a replica planchette. From there, I used the positional data of the holding controller and bound the replica planchette on the y-axis within the Ouija board. To provide the sense that the ghosts were keeping the planchette in place, I also implemented a solution where the planchette glides back to its original position if the holding controller goes out-of-bounds (See PlanchetteInteraction.cs under Code).
The initial Ouija interaction caused the distance-to-letter haptic feedback to become muddled on letter changes. To provide a break between letters, the haptic feedback is reduced to nothing as each letter is drawn on the chalkboard. This Coroutine allows the next haptic vibration to have a greater impact on the user (See Ouija.cs under Code).

A dimly lit scene with a Ouija board on a table, a white candle, and Unreal Engine's UI elements.

Planchette.cs.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.XR;
public classPlanchetteInteraction: MonoBehaviour
{
[Tooltip("The GameObject for either UnityXR Rig's controllers respectively")]
public GameObjectrightController,leftController;// Right and Left Unity XR Rig controllers
[Tooltip("The GameObject respresenting either hand and the planchette while its being held")]
public GameObjectrightHand,leftHand;// Planchette hands with Animators
// Interactable
Dictionary<string,InputDevice>hands;// e.g., "left" -> Left Unity XR InputDevice
staticpublic stringheldBy ="";// "left," "right," or "", for Ouija.cs haptics
Vector3 originalPos;// Planchettes original board position
bool reset;// Whether Planchette is floating back to original position
// Local Boundaries
float xMin= -0.593f;
float xMax= 0.394f;
float zMin= -0.205f;
float zMax= 0.400f;
private voidStart()
{
hands =new Dictionary<string, InputDevice>();
originalPos= transform.position;
}
private voidUpdate()
{
if (reset)// Floating to original position
{
// Moves towards original position
transform.position = Vector3.MoveTowards(transform.position,
originalPos,
Time.deltaTime *1.5f);
// Stops resetting once complete to allow interaction
if (transform.position.x == originalPos.x && transform.position.z == originalPos.z)
reset =false;
} else
{
if (hands.ContainsKey("right") &&!heldBy.Equals("left"))// Touching right hand
{
// If gripping...
float gripValue;
if (hands["right"].TryGetFeatureValue(CommonUsages.grip,out gripValue)&&gripValue >0.01f)
{
if(IsOutOfBounds())
{
// Signals to reset to original position, forces hand to drop planchette
reset =true;
EnableUnityXRRightHand();
}
else
{
DisableUnityXRRightHand();// Changes hands to planchette-holding hand
// Moves to controller position (ignoring y)
transform.position = new Vector3(rightController.transform.position.x,
transform.position.y,
rightController.transform.position.z);
}
}
else// If not gripping, return to unity XR hands
{
EnableUnityXRRightHand();
}
} elseif (hands.ContainsKey("left") &&!heldBy.Equals("right"))// Touching left hand
{
// If gripping...
float gripValue;
if (hands["left"].TryGetFeatureValue(CommonUsages.grip,out gripValue)&&gripValue >0.01f)
{
if(IsOutOfBounds())
{
// Signals to reset to original position, forces hand to drop planchette
reset =true;
EnableUnityXRLeftHand();
}
else
{
DisableUnityXRLeftHand();// Changes hands to planchette-holding hand
// Moves to controller position (ignoring y)
transform.position = new Vector3(leftController.transform.position.x,
transform.position.y,
leftController.transform.position.z);
}
}
else// If not gripping, return to unity XR hands
{
EnableUnityXRLeftHand();
}
}
}
}
private voidOnTriggerExit(Collider other)
{
/*
* Re-enables Unity XR hands if controllers move out of hitbox
*/
if (other.name.Equals("RightHandController"))
{
if (hands.ContainsKey("right"))
{
hands.Remove("right");
EnableUnityXRRightHand();
}
}
else if(other.name.Equals("LeftHandController"))
{
if (hands.ContainsKey("left"))
{
hands.Remove("left");
EnableUnityXRLeftHand();
}
}
}
private voidOnTriggerStay(Collider other)
{
/*
* Gets Unity XR Input Devices for positional data to be utilized
*/
if (other.name.Equals("RightHandController"))
{
// Gets characteristics of desired device (left or right controller)
List<InputDevice>devices =new List<InputDevice>();
InputDeviceCharacteristicsdeviceChars;
deviceChars= InputDeviceCharacteristics.Right;
// Receives desired device (left or right controller)
InputDevices.GetDevicesWithCharacteristics(deviceChars, devices);
if (devices.Count > 0)
{
InputDevice device= devices[0];
hands["right"] =device;
}
}
else if(other.name.Equals("LeftHandController"))
{
// Gets characteristics of desired device (left or right controller)
List<InputDevice>devices =new List<InputDevice>();
InputDeviceCharacteristicsdeviceChars;
deviceChars= InputDeviceCharacteristics.Left;
// Receives desired device (left or right controller)
InputDevices.GetDevicesWithCharacteristics(deviceChars, devices);
if (devices.Count > 0)
{
InputDevice device= devices[0];
hands["left"] =device;
}
}
}
private voidEnableUnityXRRightHand()
{
/*
* Disables the planchette with right hand attached object
* Enables Unity XR Rigs right hand mesh
*/
hands.Remove("right");
heldBy ="";// Signals to Ouija.cs that Planchette is being held by no controller
rightController.GetComponentInChildren<SkinnedMeshRenderer>().enabled = true;
rightHand.SetActive(false);
}
private voidDisableUnityXRRightHand()
{
/*
* Enables the planchette with right hand attached object
* Disables Unity XR Rigs right hand mesh
*/
heldBy ="right";// Signals to Ouija.cs that Planchette is being held by right controller
rightController.GetComponentInChildren<SkinnedMeshRenderer>().enabled = false;
rightHand.SetActive(true);
}
private voidEnableUnityXRLeftHand()
{
/*
* Disables the planchette with left hand attached object
* Enables Unity XR Rigs left hand and watch meshes
*/
hands.Remove("left");
heldBy ="";// Signals to Ouija.cs that Planchette is being held by no controller
leftController.GetComponentInChildren<SkinnedMeshRenderer>().enabled = true;
// Enables watch
foreach(MeshRenderer mesh in leftController.GetComponentsInChildren<MeshRenderer>())
{
mesh.enabled =true;
}
leftHand.SetActive(false);
}
private voidDisableUnityXRLeftHand()
{
/*
* Enables the planchette with left hand attached object
* Disables Unity XR Rigs left hand and watch meshes
*/
heldBy ="left";// Signals to Ouija.cs that Planchette is being held by left controller
leftController.GetComponentInChildren<SkinnedMeshRenderer>().enabled = false;
// Disables watch
foreach(MeshRenderer mesh in leftController.GetComponentsInChildren<MeshRenderer>())
{
mesh.enabled =false;
}
leftHand.SetActive(true);
}
private boolIsOutOfBounds()
{
/*
* Checks whether the planchette is out-of-bounds
*/
return(transform.localPosition.x > xMax|| transform.localPosition.x < xMin||
transform.localPosition.z > zMax|| transform.localPosition.z < zMin);
}
}

Ouija.cs.

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.XR;
public classOuija :MonoBehaviour
{
[Tooltip("The planchette's glass eye to calculate distance to letters")]
public GameObjectplanchette;
[Tooltip("The list containing each of the letter's triggers")]
public GameObjectletterParent;
[Tooltip("List of chalkboard writing audio clips for letter animations")]
public List<AudioClip> audio;
[Tooltip("The audio clip for chalkboard erasing")]
public AudioCliperaseAudio;
[Tooltip("The chalkboard's AudioSource")]
public AudioSourceaudioSource;
[Tooltip("The parents containing the animated letters that form their respective phrases")]
public GameObjectdangerParent,hungryParent,notesParent;
[Tooltip("The animator which opens the note drawer")]
public AnimatordrawerAnimator;
float distToCurrent;// Distance from Planchettes glass to the next letters collider
List<Vector3>lettersPos;// Positions of each letters collider
bool animatingLetter, waiting;// Tracks if animation playing / board waiting to be erased
List<GameObject>danger,hungry,notes;// The animated letter GameObjects for each word
Animator currAnimator;// The current animator (to track if animation still playing)
// Word tracking variables
List<string>words =new List<string> {"DANGER","HEISHUNGRY","FINDNOTES"};
string word="DANGER";
int lettersIndex= 0;
int wordIndex= 0;
InputDevice hand;// Hand holding the Planchette
bool puzzleComplete;// Tracks puzzle completion
private voidStart()
{
// Gets the positions of the letter cubes in Letters
lettersPos =new List<Vector3>();
foreach (Transform letter in letterParent.transform)
{
lettersPos.Add(letter.gameObject.transform.position);
}
// Gets the letters in each phrase
danger= GetChildren(dangerParent);
hungry= GetChildren(hungryParent);
notes= GetChildren(notesParent);
}
private voidUpdate()
{
if(animatingLetter)
{
// If done animating letter...
if (currAnimator.GetCurrentAnimatorStateInfo(0).normalizedTime > 1)
{
animatingLetter =false;
if (lettersIndex >= word.Length)// Word complete
{
if (word =="FINDNOTES")// Finished last word
{
// Open drawer, set Ouija puzzle as complete
puzzleComplete =true;
drawerAnimator.SetBool("Open", true);
Puzzles.ouija =true;
}
else
{
// Increment to next word, erase current word
wordIndex++;
word = words[wordIndex];
lettersIndex =0;
Erase();
}
}
}
HapticCapabilities cap;
SetHand();
if (IsHolding()&& hand.TryGetHapticCapabilities(out cap))
{
if (cap.supportsImpulse)
{
// Decreases haptic feedback through course of animation
float strength= Mathf.Clamp(1.0f- currAnimator.GetCurrentAnimatorStateInfo(0).normalizedTime,0,1);
hand.SendHapticImpulse(0, strength,0.1f);
}
}
}
else if(!puzzleComplete&& !waiting)
{
// Distance to the current, correct letter
distToCurrent= Vector3.Distance(planchette.transform.position, lettersPos[GetAlphabetIndex(word[lettersIndex])]);
// Sets hand to right or left controller devices (or null if not holding planchette)
SetHand();
// Sets haptic feedback strength based on distance to correct letter
HapticCapabilities cap;
if (IsHolding()&& hand.TryGetHapticCapabilities(out cap))
{
if (cap.supportsImpulse)
{
float strength= Mathf.Clamp(1.0f -distToCurrent,0f,1.0f);
if (strength <0.8)
{
strength *= 0.5f;
} elseif (strength <0.9)
{
strength *= 0.8f;
} else
{
strength =1.0f;
}
hand.SendHapticImpulse(0, strength,0.1f);
}
}
// Animates letter if hovering over current letter
if (distToCurrent <0.06f)
{
if (word =="DANGER")
{
AnimateLetter(danger, lettersIndex);
} elseif (word =="HEISHUNGRY")
{
AnimateLetter(hungry, lettersIndex);
} else
{
AnimateLetter(notes, lettersIndex);
}
lettersIndex++;
}
}
}
public voidAnimateLetter(List<GameObject>letters,int chalkIndex)
{
// DANGER, HE IS HUNGRY, FIND NOTES
GameObjectchalkLetter= letters[chalkIndex];
chalkLetter.SetActive(true);// Enables object
animatingLetter =true;// Tracks that animation is playing
chalkLetter.GetComponent<Animator>().Play(chalkLetter.name);// Plays letter animation
// Plays random chalkboard writing audio
System.Random rnd= new System.Random();
audioSource.clip = audio[rnd.Next(0, audio.Count-1)];
audioSource.Play();
currAnimator= chalkLetter.GetComponent<Animator>();// Sets current animator as letters animator
}
private intGetAlphabetIndex(char letter)
{
/*
* Given a letter, returns its index in the alphabet
*/
string alphabet="ABCDEFGHIJKLMNOPQRSTUVWXYZ";
return alphabet.IndexOf(letter);
}
private boolIsHolding()
{
/*
* Returns true if either hand is holding the Planchette. Otherwise, returns false.
*/
if(PlanchetteInteraction.heldBy.Equals("left")|| PlanchetteInteraction.heldBy.Equals("right"))
return true;
else
{
return false;
}
}
private voidSetHand()
{
/*
* Sets the hand to the hand holding the Planchette (left or right controller)
*/
List<InputDevice>devices =new List<InputDevice>();
InputDeviceCharacteristicsdeviceChars;
if(PlanchetteInteraction.heldBy.Equals("left"))
{
deviceChars= InputDeviceCharacteristics.Left;
InputDevices.GetDevicesWithCharacteristics(deviceChars, devices);
if (devices.Count > 0)
{
hand = devices[0];
}
}
else if(PlanchetteInteraction.heldBy.Equals("right"))
{
deviceChars= InputDeviceCharacteristics.Right;
InputDevices.GetDevicesWithCharacteristics(deviceChars, devices);
if (devices.Count > 0)
{
hand = devices[0];
}
}
}
private List<GameObject> GetChildren(GameObjectparent)
{
/*
* Returns a list of GameObjects that are children of parent
*/
List<GameObject>list =new List<GameObject>();
foreach (Transform child in parent.transform)
{
list.Add(child.transform.gameObject);
}
return list;
}
private voidDisableChildren(List<GameObject>list)
{
/*
* Disables the children in the list
*/
foreach (GameObject child in list)
{
child.SetActive(false);
}
}
private IEnumerator EraseHelper()
{
/*
* Erases the chalkboard
*/
waiting =true;
yield returnnew WaitForSeconds(1);
audioSource.PlayOneShot(eraseAudio);
yield returnnew WaitForSeconds(3);
DisableChildren(danger);
DisableChildren(hungry);
DisableChildren(notes);
waiting =false;
}
private voidErase()
{
StartCoroutine(EraseHelper());
}
}
view raw Ouija.cs hosted with ❤ by GitHub

State Changes.

Description.

Although time limits are non-existent in Hush, we wanted to provide a sense of urgency. Once the player makes progress towards their escape, several changes are seen to give this illusion. The clock's hands shift to the next quarter-hour, and a loud clock chime is heard. Dr. Montgomery's portrait becomes younger until distortion overcomes the image. In stealing their essence, he also causes the player's hands to age.

Challenges.

As a virtual escape room, Hush is a non-linear game by nature. To account for this, the state changes were required to be non-linear as well. Since the following state must make sense given the previous, an incrementing of states is needed to track their ordering.

Puzzles.cs.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public classPuzzles :MonoBehaviour
{
[Header("Portrait")]
[Tooltip("The portrait's canvas Renderer")]
public RendererportraitRenderer;
[Tooltip("The second portrait Material")]
public MaterialsecondPortrait;
[Tooltip("The third and final portrait animations placed inside the frame")]
public GameObjectthirdPortrait,fourthPortrait;
[Header("Hands")]
[Tooltip("The left hand mesh Renderer")]
public RendererleftHand;
[Tooltip("The right hand mesh Renderer")]
public RendererrightHand;
[Tooltip("The materials for the second, third, and fourth hands")]
public Materialhand2,hand3,hand4;
[Header("Clock")]
[Tooltip("The clock's animator")]
public AnimatorclockAnim;
[Tooltip("The clock's audio source")]
public AudioSourceclockChime;
publicstaticbool candles, ouija, tile, book;
bool isOuijaSwitched, isTileSwitched, isBookSwitched;
int stateIndex= 0;
void Update()
{
// State Changes
if (ouija &&!isOuijaSwitched)// Ouija board complete
{
isOuijaSwitched =true;
StateChange();
} elseif (tile &&!isTileSwitched)// Ankh tiles complete
{
isTileSwitched =true;
StateChange();
} elseif (book &&!isBookSwitched) // Book complete
{
isBookSwitched =true;
StateChange();
}
}
void StateChange()
{
/*
* Increments the current state of the game
* Allows state changes if puzzles are completed out of order
*/
stateIndex++;
if (stateIndex >3)
return;
if (stateIndex ==1)
{
// Changes to young portrait, ages hands slightly
portraitRenderer.material = secondPortrait;
leftHand.material =hand2;
rightHand.material =hand2;
clockAnim.Play("Clock_1115");
} elseif (stateIndex ==2)
{
// Changes to glitchy portrait, ages hands further
portraitRenderer.gameObject.SetActive(false);
thirdPortrait.SetActive(true);
leftHand.material =hand3;
rightHand.material =hand3;
clockAnim.Play("Clock_1130");
} else
{
// Changes to scary portrait, hands fully aged
thirdPortrait.SetActive(false);
fourthPortrait.SetActive(true);
leftHand.material =hand4;
rightHand.material =hand4;
clockAnim.Play("Clock_1145");
}
clockChime.PlayOneShot(clockChime.clip);
}
}
view raw Puzzles.cs hosted with ❤ by GitHub