Technical Paper — Unity 6 Systems Design and Implementation
A Knock at the Door is a first-person psychological horror game made in Unity 6, released on itch.io. It started as a class project for GDD-211 (Unity) and ended up being something I'm genuinely proud of. I was the only Unity developer on the team, so everything in-engine was on me: every room, every system, every interaction, every script.
This paper covers the technical systems I built, the design decisions behind them, and how they fit together. The code is all C# in Unity 6 using standard MonoBehaviour and coroutine patterns, plus ProBuilder for the geometry.
The interaction system is the backbone of the whole game. Almost everything the player
does — opening doors, picking up objects, reading notes, triggering cutscenes — runs through
two scripts: PlayerInteractor.cs and Interactable.cs.
PlayerInteractor lives on the player and fires a raycast from the camera each
frame. If the ray hits a collider on the Interactable layer within range, it
stores a reference to that object and draws a UI prompt. When the player presses the interact
key, it calls Interact() on the target. Interactable is a base class
that holds the type and delegates to subclasses or Unity Events for the actual logic.
// PlayerInteractor.cs public class PlayerInteractor : MonoBehaviour { [SerializeField] private float interactRange = 2.5f; [SerializeField] private LayerMask interactLayer; [SerializeField] private GameObject interactPrompt; private Interactable _current; private Camera _cam; void Start() => _cam = Camera.main; void Update() { if (Physics.Raycast(_cam.transform.position, _cam.transform.forward, out RaycastHit hit, interactRange, interactLayer)) { _current = hit.collider.GetComponent<Interactable>(); interactPrompt.SetActive(_current != null); } else { _current = null; interactPrompt.SetActive(false); } if (Input.GetKeyDown(KeyCode.E) && _current != null) _current.Interact(this); } }
Interactable uses an enum to declare what kind of object it is. The type is
read by PlayerInteractor and door-gate logic to decide what prompt to show and
whether the interaction is available. Nine types cover every use case in the game — doors,
pickups, notes, dialogue triggers, switches, and more.
// InteractableType enum — nine types cover every interactive in the game public enum InteractableType { Door, // standard openable door LockedDoor, // requires a key item JammedDoor, // won't open — used for blocking routes LanternDoor, // requires the lantern to be held Pickup, // adds item to inventory Note, // opens text panel Dialogue, // triggers a dialogue sequence Switch, // toggles a flag or object state Cutscene, // locks player and plays a sequence } public class Interactable : MonoBehaviour { public InteractableType type; public UnityEvent<PlayerInteractor> onInteract; public void Interact(PlayerInteractor player) { onInteract.Invoke(player); } }
The UI prompt is a world-space canvas that follows the raycast hit point. It fades in
when a valid target is detected and fades out when the player looks away, giving tactile
feedback about what's interactable without cluttering the screen with a permanent HUD icon.
The prompt text changes based on the InteractableType — "Open", "Examine",
"Take" — so the player always knows what action they're about to take.
Doors are the most complex interactive in the game. A single door can be in one of several
states — open, closed, locked, jammed, or gated behind a progression flag — and the state can
change mid-game as the player collects items or triggers events. The door logic is handled
in a DoorController script that checks state before executing the open animation.
Each door has a DoorState enum and a set of serialized references to the
conditions that gate it. On interact, the controller evaluates the current state and responds
accordingly — playing a jiggle animation for locked doors, showing a dialogue prompt for
gated doors, or triggering the open coroutine for accessible ones.
// DoorController.cs public enum DoorState { Closed, Open, Locked, Jammed, LanternGated } public class DoorController : MonoBehaviour { public DoorState state = DoorState.Closed; public string requiredFlag = ""; // progression flag gating this door public float openAngle = 90f; private bool _isAnimating = false; public void TryOpen(PlayerInteractor player) { if (_isAnimating) return; switch (state) { case DoorState.Open: StartCoroutine(AnimateClose()); break; case DoorState.Closed: StartCoroutine(AnimateOpen()); break; case DoorState.Locked: StartCoroutine(JiggleDoor()); DialogueSystem.Show("It's locked."); break; case DoorState.Jammed: StartCoroutine(JiggleDoor()); DialogueSystem.Show("It won't budge."); break; case DoorState.LanternGated: if (player.HasItem("Lantern")) StartCoroutine(AnimateOpen()); else DialogueSystem.Show("Something feels wrong about this door."); break; } } // called by a GameManager event when a required flag is set public void Unlock() => state = DoorState.Closed;
Doors open with a smooth rotation over a fixed duration using Quaternion.Lerp.
The _isAnimating flag prevents the player from re-interacting mid-animation,
which would cause the door to snap or jitter. After the animation, the state is flipped and
any dynamically rerouted flags are applied — some doors change which paths are available
based on whether they're open or closed.
IEnumerator AnimateOpen() { _isAnimating = true; Quaternion start = transform.localRotation; Quaternion target = Quaternion.Euler(0, openAngle, 0) * start; float elapsed = 0f, duration = 0.6f; while (elapsed < duration) { elapsed += Time.deltaTime; transform.localRotation = Quaternion.Lerp(start, target, elapsed / duration); yield return null; } transform.localRotation = target; // snap to exact target at end state = DoorState.Open; _isAnimating = false; // notify GameManager — some doors trigger rererouting when opened GameManager.Instance.OnDoorOpened(gameObject.name); }
The dialogue system handles both collision-triggered text and script-called text. It's a single canvas panel that fades in, types out text character by character, then fades out after a delay. It supports both immediate calls (for door feedback) and queued sequences (for multi-line story beats triggered by walking into a zone).
Text is revealed one character at a time with a configurable delay per character. The
coroutine fills a TextMeshPro component incrementally. Players can press
interact to skip to the end — the coroutine is stopped and the full string is set immediately.
IEnumerator TypeText(string message) { _textMesh.text = ""; _skipRequested = false; foreach (char c in message) { if (_skipRequested) { _textMesh.text = message; break; } _textMesh.text += c; yield return new WaitForSeconds(charDelay); } yield return new WaitForSeconds(displayDuration); StartCoroutine(FadeOut()); } public void SkipDialogue() => _skipRequested = true;
Walking into a trigger collider fires a dialogue sequence. The trigger zone script holds a string array for multi-line sequences and a flag to prevent re-firing. Each line is displayed in order, with the next line waiting for the previous fade-out before starting.
public class DialogueTrigger : MonoBehaviour { [SerializeField] private string[] lines; private bool _fired = false; void OnTriggerEnter(Collider other) { if (_fired) return; if (!other.CompareTag("Player")) return; _fired = true; StartCoroutine(PlaySequence()); } IEnumerator PlaySequence() { foreach (var line in lines) { yield return StartCoroutine(DialogueSystem.Instance.ShowAndWait(line)); } } }
Timothy is the game's enemy, implemented in TimothyAI.cs. He uses locked-axis
movement (he can only move along one plane, matching the level geometry), smooth rotation
toward the player, activation gating behind a progression flag, and a gun-raycast defeat
detection system where the player must shoot him to end the chase.
Timothy's movement is constrained to the XZ plane — he can't jump or fall. Each frame he moves toward the player's position with the Y component clamped to his spawn height. This keeps him reliably navigating the corridors without needing a NavMesh in the early blockout phase, and the level geometry naturally contains him.
// TimothyAI.cs — simplified movement loop void Update() { if (!_active) return; Vector3 target = _player.position; target.y = transform.position.y; // lock Y — no vertical movement // smooth rotation toward player Vector3 dir = (target - transform.position).normalized; if (dir != Vector3.zero) { Quaternion look = Quaternion.LookRotation(dir); transform.rotation = Quaternion.Slerp( transform.rotation, look, rotationSpeed * Time.deltaTime ); } // move forward at chase speed transform.position = Vector3.MoveTowards( transform.position, target, chaseSpeed * Time.deltaTime ); }
Timothy starts inactive. A progression flag set by GameManager — triggered
by the player reaching a specific point in the game — calls Activate().
This prevents him from chasing the player before the game is ready for the chase sequence,
without any complex scene management.
public void Activate() { _active = true; _animator.SetTrigger("Wake"); AudioManager.Instance.PlayChaseMusic(); } public void Deactivate() { _active = false; _animator.SetTrigger("Idle"); AudioManager.Instance.StopChaseMusic(); }
The player has a gun as a last-resort mechanic. When fired, the gun casts a ray from the
camera. If the ray hits Timothy's collider within a generous hitbox radius, he's defeated —
Defeat() is called, triggering the appropriate ending branch.
The detection is raycast-based rather than physics-trigger-based so it works reliably even
when Timothy is partially behind cover.
// GunController.cs void Shoot() { if (_ammo <= 0) { DialogueSystem.Show("No ammo."); return; } _ammo--; if (Physics.Raycast(_cam.position, _cam.forward, out RaycastHit hit, 20f)) { TimothyAI timothy = hit.collider.GetComponent<TimothyAI>(); if (timothy != null) { timothy.Defeat(); GameManager.Instance.SetFlag("TimothyDefeated"); } } }
The game has two endings: the Padded Room ending and the Hospital ending. Both involve player control lockouts, camera clamping, canvas fade transitions, and a final state change. Each is a coroutine chain — once triggered, the sequence runs to completion regardless of player input.
At the start of each ending, the player's movement and look scripts are disabled. The
camera is then lerped to a target position and rotation — a specific angle that frames the
final scene. Camera clamping is implemented as a LockCamera coroutine that
smoothly overrides the camera's transform.
IEnumerator LockCameraToTarget(Transform camTarget, float duration) { Transform cam = Camera.main.transform; Vector3 startPos = cam.position; Quaternion startRot = cam.rotation; float elapsed = 0f; while (elapsed < duration) { elapsed += Time.deltaTime; float t = elapsed / duration; cam.position = Vector3.Lerp(startPos, camTarget.position, t); cam.rotation = Quaternion.Slerp(startRot, camTarget.rotation, t); yield return null; } cam.position = camTarget.position; cam.rotation = camTarget.rotation; }
ScreenFader is a singleton MonoBehaviour managing a fullscreen canvas overlay.
It's used throughout the game for teleportation (fade out → move player → fade in) and for
the ending transitions (fade to black). Being a singleton means any script can call
ScreenFader.Instance.FadeOut() without needing a scene reference.
public class ScreenFader : MonoBehaviour { public static ScreenFader Instance { get; private set; } [SerializeField] private CanvasGroup overlay; void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; DontDestroyOnLoad(this); } public IEnumerator FadeOut(float duration = 1f) { float elapsed = 0f; while (elapsed < duration) { elapsed += Time.deltaTime; overlay.alpha = elapsed / duration; yield return null; } overlay.alpha = 1f; } public IEnumerator FadeIn(float duration = 1f) { float elapsed = 0f; while (elapsed < duration) { elapsed += Time.deltaTime; overlay.alpha = 1f - (elapsed / duration); yield return null; } overlay.alpha = 0f; } /// Teleport utility: fade out, move player, fade in. public IEnumerator FadeAndTeleport(Transform player, Transform destination) { yield return StartCoroutine(FadeOut()); player.position = destination.position; player.rotation = destination.rotation; yield return StartCoroutine(FadeIn()); } }
The Padded Room ending plays when the player reaches the room at the end of the chase and doesn't shoot Timothy in time. The sequence: disable player controls, lock camera to the padded room angle, play a dialogue sequence, fade to black, show the end card. The whole thing is one coroutine that waits on each step before proceeding.
IEnumerator PaddedRoomEnding() { // 1. lock player _player.GetComponent<PlayerMovement>().enabled = false; _player.GetComponent<MouseLook>().enabled = false; // 2. lock camera to final angle yield return StartCoroutine(LockCameraToTarget(paddedRoomCamTarget, 1.5f)); // 3. dialogue yield return StartCoroutine(DialogueSystem.Instance.ShowAndWait( "You always end up here eventually." )); yield return new WaitForSeconds(1.5f); // 4. fade out and show end card yield return StartCoroutine(ScreenFader.Instance.FadeOut(2f)); endCard.SetActive(true); }
The most technically complex sequence in the game is the final door cutscene — the moment the player reaches the front door. It coordinates six simultaneous systems in a single coroutine: spawning a character, adjusting player speed, animating a light source, rotating the door, activating Timothy, and starting chase music — all timed together so they feel like one event rather than six separate things happening at once.
IEnumerator FinalDoorCutscene() { // disable input during cutscene _player.GetComponent<PlayerMovement>().enabled = false; // 1. spawn the silhouette figure at the door GameObject figure = Instantiate(silhouettePrefab, doorSpawnPoint.position, doorSpawnPoint.rotation); // 2. slow player walk speed for the dramatic approach yield return new WaitForSeconds(0.3f); _player.GetComponent<PlayerMovement>().enabled = true; _player.GetComponent<PlayerMovement>().moveSpeed = slowWalkSpeed; // 3. flicker the lantern light StartCoroutine(FlickerLight(lanternLight, duration: 1.2f)); // 4. animate the door rotating open from the other side yield return new WaitForSeconds(0.8f); StartCoroutine(RotateDoor(finalDoor, -110f, duration: 1.0f)); // 5. activate Timothy and start chase music simultaneously yield return new WaitForSeconds(1.2f); _timothyAI.Activate(); AudioManager.Instance.PlayChaseMusic(); // 6. restore normal move speed and re-enable full input yield return new WaitForSeconds(0.5f); _player.GetComponent<PlayerMovement>().moveSpeed = normalMoveSpeed; } IEnumerator FlickerLight(Light light, float duration) { float end = Time.time + duration; float baseI = light.intensity; while (Time.time < end) { light.intensity = baseI * Random.Range(0.3f, 1.1f); yield return new WaitForSeconds(Random.Range(0.04f, 0.1f)); } light.intensity = baseI; }
All audio in the game goes through a centralised AudioManager singleton.
This keeps volume control, music transitions, and positional audio consistent. Background
ambience fades in on scene load; one-shot sounds (door creaks, footsteps, the gun) are played
via pooled AudioSource components to avoid the overhead of AudioSource.PlayClipAtPoint.
The game has three musical states: ambient, tense, and chase. Transitions between states
are crossfades — the outgoing track fades out while the incoming track fades in over a
configurable duration. This avoids the jarring hard cut that a direct Stop()/Play()
sequence would produce.
IEnumerator CrossfadeTo(AudioClip newClip, float duration = 1.5f) { float startVol = _musicSource.volume; float elapsed = 0f; // fade out current track while (elapsed < duration / 2f) { elapsed += Time.deltaTime; _musicSource.volume = Mathf.Lerp(startVol, 0f, elapsed / (duration / 2f)); yield return null; } // swap clip and fade in _musicSource.clip = newClip; _musicSource.Play(); elapsed = 0f; while (elapsed < duration / 2f) { elapsed += Time.deltaTime; _musicSource.volume = Mathf.Lerp(0f, startVol, elapsed / (duration / 2f)); yield return null; } _musicSource.volume = startVol; } public void PlayChaseMusic() => StartCoroutine(CrossfadeTo(chaseMusic, 0.8f)); public void PlayAmbient() => StartCoroutine(CrossfadeTo(ambientMusic, 2f));
Timothy's footsteps use a 3D AudioSource attached directly to him, with spatial blend set to 1.0 (fully spatial). As he gets closer, his footsteps grow louder and shift in stereo position, letting the player hear which direction he's coming from without needing to see him. The distance falloff curve is set to logarithmic to match the feel of footsteps in a physical corridor.