Gameplay / System Programmer

When the Lights Die

Stealth-horror prototype built in Unity around light, exposure, and reactive enemy pressure.

When the Lights Die is a stealth-horror prototype where light is both the central mechanic and the main threat. Built in Unity as a group project, the game puts the player in the role of a vampire who must manipulate darkness, avoid deadly exposure, and outplay a tension-driven boss encounter. My work focused on building the programming backbone of that experience through reusable systems for light interaction, AI behavior, audio feedback, and gameplay progression.

Role: Gameplay Programmer - AI - Audio System

Team Size: 8

5 weeks

Unity (C#)

Overview

When the Lights Die is a Unity stealth-horror group project built around a strong light-versus-darkness gameplay fantasy. The player takes the role of a vampire moving through a hostile environment where light is not just visual atmosphere, but an active gameplay threat tied to exposure, stealth, and progression. The core loop revolves around avoiding or manipulating dangerous light sources, extinguishing key holy lights, and surviving the pressure of a reactive Paladin encounter that turns detection and sound into constant tension.

From a programming perspective, my role was to help turn that concept into a cohesive playable experience through modular gameplay systems. I was responsible for major parts of the project’s core loop, especially the light system, player light exposure, interactive extinguishable lights, Paladin boss AI, and the audio/SFX pipeline, while also contributing to integration features such as the win-condition flow, map UI, door interaction, splash sequence, and overall debugging so the different systems worked reliably together in the final level.

LIGHT SYSTEM & EXPOSURE

A major part of my contribution was building the light gameplay system that turned light into an active threat rather than just a visual element. By connecting sunlight, harmful light zones, player exposure, and interactive lights, I helped make movement, timing, and environmental awareness central to the stealth loop.

The system included a day/night controller, modular LightZones, continuous exposure tracking, and sun occlusion raycasts so level geometry could correctly block sunlight. I also implemented ExtinguishableLight and the VampireLightBreaker ability, allowing players to disable lights through a feedback-driven, event-based interaction system that also tied into AI reactions and objective progression.

TimeOfDayManager.cs — Day/Night Lighting Control

private void Update()
{
    timeOfDay += Time.deltaTime / dayLengthSeconds;
    if (timeOfDay > 1f) timeOfDay -= 1f;

    // rotate sun (and moon opposite)
    float sunAngle = timeOfDay * 360f - 90f;
    sunLight.transform.rotation = Quaternion.Euler(sunAngle, 0f, 0f);
    moonLight.transform.rotation = sunLight.transform.rotation * Quaternion.Euler(0f, 180f, 0f);

    // smooth dayFactor with blend at dawn/dusk
    float dawnFactor = SmoothStep01(DayStart, dawnEnd, timeOfDay);
    float duskFactor = SmoothStep01(duskStart, DayEnd, timeOfDay);
    float dayFactor = dawnFactor * (1f - duskFactor);

    float sunLux = Mathf.Lerp(minSunLux, maxSunLux, sunIntensityCurve.Evaluate(timeOfDay)) * dayFactor;
    sunLight.intensity = sunLux;
    moonLight.intensity = moonLuxAtNight * (1f - dayFactor);

    // HDRP: only one directional light casts cascaded shadows at a time
    bool sunCasts = dayFactor >= shadowSwitchThreshold;
    sunLight.shadows  = sunCasts ? LightShadows.Soft : LightShadows.None;
    moonLight.shadows = sunCasts ? LightShadows.None : LightShadows.Soft;
}
  

Problem

The challenge was to make light work as a core gameplay threat rather than just a visual effect. It needed to be dangerous, readable, and flexible enough to support changing daylight, different harmful light sources, and clear gameplay consequences.

At the same time, the system had to be interactive. Players needed ways to respond to light through action, while those interactions also had to connect cleanly to AI behavior, feedback, and objective progression without creating tightly coupled systems.

Solution

I built the light mechanic as a modular system made up of a TimeOfDayManager, reusable LightZones, and continuous player exposure tracking. This allowed light to react dynamically to level geometry, time of day, and different harmful sources instead of working as a simple binary check.

I also added sun occlusion, plus ExtinguishableLight and the VampireLightBreaker ability, so players could strategically disable threats rather than only avoid them. Because the system was event-driven, light interactions could also feed into AI reactions, objectives, UI, and audio without hard-coded dependencies.

PlayerLightExposure.cs — Continuous Exposure Tracking

private void Update()
{
    float targetExposure = 0f;
    float targetMoonExposure = 0f;
    var tod = TimeOfDayManager.Instance;

    foreach (var zone in currentLightZones)
    {
        if (!zone.IsOn) continue;

        if (zone.IsSunbeamWindow && tod != null && tod.IsDaytime)
        {
            float strength = zone.DangerStrength;
            if (!IsInDirectSunlight()) strength = 0f;
            targetExposure += strength;
            continue;
        }

        targetExposure += zone.DangerStrength;   // torches, chandeliers, etc.
    }

    currentExposure = Mathf.MoveTowards(currentExposure, targetExposure, exposureDecayRate * Time.deltaTime);
    currentMoonExposure = Mathf.MoveTowards(currentMoonExposure, targetMoonExposure, exposureDecayRate * Time.deltaTime);
}
  
PlayerLightExposure.cs — Sun Occlusion Raycast

private bool IsInDirectSunlight()
{
    var tod = TimeOfDayManager.Instance;
    if (tod == null || !tod.IsDaytime) return false;

    Vector3 origin   = transform.position + Vector3.up * sunCheckHeight;
    Vector3 dirToSun = tod.SunDirection.normalized;

    // Hit anything on the occluder layers and the sun is blocked
    if (Physics.Raycast(origin, dirToSun, out RaycastHit hit,
            sunCheckDistance, sunOccluderLayers, QueryTriggerInteraction.Ignore))
    {
        return false;
    }

    return true;
}
  

Technical Breakdown

Technically, I structured the light mechanic as a set of reusable systems. A TimeOfDayManager handled sun/moon movement and day-night blending, while LightZone volumes defined harmful light areas with tunable danger strength. PlayerLightExposure tracked overlapping zones and calculated continuous exposure, and a sun-occlusion raycast ensured that geometry like roofs and walls could correctly block sunlight.

For player interaction, I built ExtinguishableLight and the VampireLightBreaker ability. These handled light targeting, feedback, cooldowns, animation, and SFX, while exposing clean events that other systems could subscribe to. This event-driven setup allowed the light mechanic to feed into objectives, audio, and AI behaviour without tightly coupling the code, making it easier to scale and integrate across the project.

ExtinguishableLight.cs — Toggle, Noise, and Event

public event Action<ExtinguishableLight> OnExtinguished;

public bool TryExtinguish()
{
    if (locked) return false;

    if (mode == ExtinguishMode.Permanent)
    {
        permanentlyOff = true;
        SetLightsEnabled(false);
        EmitNoiseIfEnabled();
        OnExtinguished?.Invoke(this);
        return true;
    }

    extinguishRoutine = StartCoroutine(ExtinguishRoutine());
    return true;
}

private void EmitNoiseIfEnabled()
{
    if (!emitNoiseOnExtinguish) return;
    NoiseSystem.EmitNoise(new NoiseEvent {
        position = transform.position,
        intensity = lightSwitchNoiseIntensity,
        type = NoiseType.LightSwitch,
        instigator = gameObject
    });
}
  
VampireLightBreaker.cs — Targeting, Cooldown, Animation, SFX

private void TryUsePower()
{
    if (cooldownTimer > 0f) return;

    if (Physics.Raycast(cameraRoot.position, cameraRoot.forward,
            out RaycastHit hit, maxRange, lightRaycasstMask, QueryTriggerInteraction.Ignore))
    {
        var lightTarget = hit.collider.GetComponentInParent<ExtinguishableLight>();
        if (lightTarget != null && lightTarget.TryExtinguish())
        {
            cooldownTimer = cooldownSeconds;
            bodyAnimator.SetTrigger(ExtinguishHash);

            var emitter = lightTarget.GetComponentInChildren<SFXEmitter>();
            if (emitter != null) emitter.Play();
            else AudioManager.Instance?.PlaySFX(SFXId.Lights_Switch, hit.point);
        }
    }
}
  

PALADIN BOSS AI

I developed the Paladin Boss AI to create tension through sight, hearing, suspicion, and pacing instead of a basic chase pattern. The aim was to make the boss feel reactive and dangerous while still giving the player readable behaviour and room for stealth-based choices.

The system combined a blackboard, AI controller, and director layer to support patrol, investigation, pursuit, search, and safe-room reactions. A PaladinConfig ScriptableObject exposed the main tuning values, making the behaviour easier to iterate on during development

PaladinBlackboard.cs — Shared State for Controller, Perception, Director

public enum PaladinState
{
    Dormant, Patrol, InvestigateNoise, Chase,
    SearchLastKnownPosition, WaitOutsideSafeRoom, ForcedReset,
}

public class PaladinBlackboard : MonoBehaviour
{
    public PaladinConfig config;

    public PaladinState currentState = PaladinState.Dormant;
    public float timeInCurrentState;
    public float timeSinceLastEncounter;

    public bool    canSeePlayer;
    public Vector3 lastSeenPlayerPos;
    public float   timeSinceLastSeen = Mathf.Infinity;

    public bool    hasNoiseTarget;
    public Vector3 lastHeardNoisePos;
    public float   lastNoisePriority;

    public float suspicionLevel;
    public float timeSinceLastSuspicionNoise = Mathf.Infinity;

    public bool          isPlayerInSafeRoom;
    public SafeRoomZone currentSafeRoom;
    public Vector3      currentDestination;
}
  

Problem

The challenge was to design a boss AI that could create fear through perception, suspicion, pursuit, and pacing, rather than relying on a simple chase behavior. The Paladin needed to react to both sight and sound, remain readable to the player, and sustain pressure over time without feeling random or unfair. At the same time, the AI had to integrate cleanly with safe rooms, noise systems, doors, animation, and audio, while staying modular enough to tune during production.

Solution

I approached the Paladin as a layered AI system rather than one large enemy script. Shared information like vision, noise, suspicion, timers, and safe-room state lived in a Blackboard, behaviour ran through the AI Controller with NavMeshAgent, and a PaladinDirector handled encounter pacing. This made the AI easier to tune, debug, and expand.

The behaviour was built around a state machine covering patrol, sound investigation, pursuit, search, and safe-room waiting. Detection combined vision, hearing, and a suspicion system, allowing danger to build gradually instead of switching instantly from calm to chase.

To keep the encounter tense without becoming frustrating, the Director adjusted aggression over time by shifting mood states and changing movement speed and suspicion thresholds. I also exposed the key values through PaladinConfig, so the behaviour could be iterated on quickly and integrated cleanly with safe rooms, doors, and boss audio.

PaladinAIController.cs — State Machine + NavMesh-Based Transitions

private void Update()
{
    blackboard.timeInCurrentState += Time.deltaTime;
    blackboard.timeSinceLastEncounter += Time.deltaTime;

    switch (blackboard.currentState)
    {
        case PaladinState.Patrol:
            UpdatePatrol();
            if (blackboard.canSeePlayer)        ChangeState(PaladinState.Chase);
            else if (blackboard.hasNoiseTarget) ChangeState(PaladinState.InvestigateNoise);
            break;
        case PaladinState.Chase:                    UpdateChase(); break;
        case PaladinState.SearchLastKnownPosition:   UpdateSearch(); break;
        case PaladinState.InvestigateNoise:          UpdateInvestigateNoise(); break;
        case PaladinState.WaitOutsideSafeRoom:       UpdateWaitOutsideSafeRoom(); break;
    }
}

private void OnEnterChase()
{
    agent.speed = GetEffectiveSpeed(blackboard.config.chaseSpeed);  // director multiplier
    agent.isStopped = false;
    Vector3 targetPos = (player != null) ? player.position : blackboard.lastSeenPlayerPos;
    blackboard.currentDestination = targetPos;
    agent.SetDestination(targetPos);
}
  

Technical Breakdown

The Paladin was built as a multi-layered AI system rather than a single enemy script. A Blackboard stored shared state, the AI Controller handled navigation and behaviour execution, and the PaladinDirector managed pacing and tension across the encounter. This kept the boss logic easier to debug, tune, and expand.

Its behaviour was driven by a state machine covering patrol, sound investigation, pursuit, search, and safe-room waiting. I also implemented patrol routing, lost-sight handling, randomized search points using NavMesh sampling, and close-range melee attacks with animation-event-driven damage and cooldown control.

The perception model combined vision, hearing, and suspicion. Vision relied on range, field-of-view, and line-of-sight checks, while hearing reacted to the global NoiseSystem and selected the most relevant sound to investigate. On top of that, a suspicion meter allowed danger to build progressively instead of switching instantly into a chase.

To shape the encounter over time, the PaladinDirector adjusted mood and aggression through states like calm, alert, and cooldown, influencing movement speed and chase sensitivity. Key values were exposed through PaladinConfig, and the system was tied into safe rooms, doors, and boss-specific audio cues for a complete gameplay loop.

PaladinPerception.cs — FOV + Line-of-Sight Check

float distance = toPlayer.magnitude;
if (distance > blackboard.config.sightRange) { LoseSight(); return; }

float angleToPlayer = Vector3.Angle(transform.forward, dir);
if (angleToPlayer > blackboard.config.sightAngle * 0.5f) { LoseSight(); return; }

if (Physics.Raycast(origin, dir, out RaycastHit hit, blackboard.config.sightRange, visionBlockers))
{
    if (!hit.collider.CompareTag("Player")) { LoseSight(); return; }
}
else { LoseSight(); return; }

blackboard.canSeePlayer = true;
blackboard.lastSeenPlayerPos = player.position;
blackboard.timeSinceLastSeen = 0f;
  
PaladinHearing.cs — Suspicion Gain + Director-Modulated Forced Chase

float distanceFactor = 1f - (distance / maxHearDistance);
float priority = distanceFactor * GetTypeWeight(noise.type);

float suspicionGain = GetSuspicionGain(noise.type, noise.intensity);
if (suspicionGain > 0f)
{
    float baseThreshold = blackboard.config.suspicionThreshold;
    float effectiveThreshold = baseThreshold *
        ((director != null) ? director.SuspicionThresholdMultiplier : 1f);

    float prevLevel = blackboard.suspicionLevel;
    blackboard.suspicionLevel = Mathf.Min(blackboard.suspicionLevel + suspicionGain, baseThreshold);
    blackboard.timeSinceLastSuspicionNoise = 0f;

    if (prevLevel < effectiveThreshold && blackboard.suspicionLevel >= effectiveThreshold)
        controller?.ForceChaseToPlayer();
}
  
PaladinDirector.cs — Mood-Driven Aggression Multipliers

private void UpdateMood(float suspicionRatio, float timeSinceLastEncounter, PaladinState paladinState)
{
    PaladinDirectorMood nextMood = CurrentMood;
    switch (CurrentMood)
    {
        case PaladinDirectorMood.Calm:
            if (suspicionRatio >= buildingSuspicionThreshold) nextMood = PaladinDirectorMood.Building;
            break;
        case PaladinDirectorMood.Building:
            if (suspicionRatio >= highAlertSuspicionThreshold) nextMood = PaladinDirectorMood.HighAlert;
            else if (suspicionRatio <= 0.05f && timeSinceLastEncounter > minEncounterInterval)
                nextMood = PaladinDirectorMood.Calm;
            break;
        case PaladinDirectorMood.HighAlert:
            if (suspicionRatio <= buildingSuspicionThreshold && timeSinceLastEncounter > 5f)
                nextMood = PaladinDirectorMood.Cooldown;
            break;
        case PaladinDirectorMood.Cooldown:
            if (timeInCurrentMood >= cooldownDuration && suspicionRatio <= buildingSuspicionThreshold)
                nextMood = PaladinDirectorMood.Calm;
            break;
    }
    if (nextMood != CurrentMood) SetMood(nextMood);
}

// Each mood writes new multipliers used by the AI controller and hearing system
public float MoveSpeedMultiplier { get; private set; } = 1f;
public float SuspicionThresholdMultiplier { get; private set; } = 1f;
  

SUPPORTING SYSTEM & INTEGRATION

Alongside the project’s main gameplay systems, I also implemented several supporting features that helped connect interaction, AI behavior, objectives, UI, and presentation into a more complete and playable stealth-horror experience.

Safe Room Logic

Built SafeRoomZone triggers that let the Paladin detect when the player entered a safe room, creating clear recovery spaces and changing boss behaviour.

Door Interaction

Implemented VampireDoorInteractor, a camera-raycast interaction system for opening and closing doors through a modular player input workflow.

Noise System

Built a global NoiseSystem for gameplay events and AI reactions, and implemented the HolyLightWinCondition objective flow through event-driven light extinguishing.

Map UI & Player Feedback

Implemented MapToggle and MapPlayerIcon so the player could open the map and track their position clearly during navigation.

Splash / Startup Sequence

Created SplashSequenceController to manage intro screens, transitions, skip support, and loading into the main menu.