A Knock at the Door

Technical Paper — Unity 6 Systems Design and Implementation

Context

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.

Interaction System

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.

Architecture

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 Types

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);
    }
}

Prompt UI and Range Feedback

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.

Multi-State Door Logic

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.

State Machine

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;

Open Animation Coroutine

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);
}

Dialogue System

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).

TypeWriter Effect

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;

Trigger Zones

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));
        }
    }
}

Enemy AI — Timothy

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.

Locked-Axis Movement

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
    );
}

Activation Gating

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();
}

Gun-Raycast Defeat Detection

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");
        }
    }
}

Ending Sequences

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.

Control Lockout and Camera Clamp

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 Singleton

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());
    }
}

Padded Room Ending

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);
}

Final Door Cutscene

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;
}

Sound Design Implementation

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.

Music Transition

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));

Positional Audio

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.