Roblox Lighting Zones: Scripting Per-Room Atmosphere Transitions for Horror and Liminal Worlds

Have you ever walked a player out of a sodium-lit corridor and into a flooded basement, only to watch the lighting stay exactly the same? In horror and liminal games, that moment breaks the spell faster than a badly timed jump scare — the geometry has changed, yet the air hasn't.
Roblox gives you one global Lighting service per place, so per-room atmosphere is something you build on top of the engine. This guide walks through that build: zone volumes, spatial checks, TweenService blends and client-side overrides that let every room carry its own mood.
If you need the fundamentals of what each Lighting property does, start with our Roblox lighting and atmosphere guide. Everything below assumes you already know what Ambient, ExposureCompensation and Atmosphere.Haze do on their own.
A Roblox lighting zone is an invisible, tagged part that names a lighting preset. A LocalScript detects which zone contains the player and tweens Lighting, Atmosphere and post effects to match that room.
How A Lighting Zone System Fits Together
Before writing a line of Luau, it helps to map the moving parts. A production-ready zone system has five pieces, and keeping them separate makes each one easier to debug:
- Zone volumes. Invisible, non-colliding parts placed around each room and tagged
LightingZonewith CollectionService. Each carries aPresetattribute and an optionalPriorityattribute. - Preset definitions. A ModuleScript in ReplicatedStorage holding target values for Lighting, Atmosphere and color grading per room type. Designers can tune numbers here without touching logic.
- A resolver. A pure function that takes a position and a list of zones and returns the winning zone. Because it has no side effects, it's the piece you can unit test.
- A blender. The code that cancels in-flight tweens and starts new ones toward the target preset. It owns every TweenService call in the system.
- A client driver. A LocalScript that polls the character's position on a fixed interval and hands zone changes to the blender. It's the only piece that knows about the player.
All of these follow the module-per-responsibility layout covered in our Luau scripting patterns guide. Keep in mind that the whole system runs on the client — the server only places the zone parts.
Defining Zones And Presets
Start by placing a part around each room, slightly larger than the walls so the player registers inside it before fully clearing the doorway. Set Transparency to 1, Anchored to true, and CanCollide and CanTouch to false.
Next, tag each part LightingZone and add a string attribute called Preset — Hallway, Basement, Office and so on. Rooms nested inside larger spaces, such as a closet inside a warehouse, get a numeric Priority attribute so the smaller volume wins.
The presets themselves live in one ModuleScript. Here's a trimmed version with a single room type:
```lua -- ReplicatedStorage/LightingPresets local Presets = {} Presets.Hallway = { Lighting = { Ambient = Color3.fromRGB(40, 36, 30), ExposureCompensation = -0.5, }, Atmosphere = { Density = 0.3, Haze = 1.5, Color = Color3.fromRGB(120, 110, 90), }, ColorCorrection = { Saturation = -0.2, Contrast = 0.1, TintColor = Color3.fromRGB(255, 240, 215), }, BlendTime = 1.5, } return Presets ```Notice that each sub-table maps directly onto one instance's property names. That design choice lets the blender pass each table straight into TweenService:Create without any translation layer.
You'll also want a Default preset that captures your place's original Lighting values. Without it, a player who steps outside every zone has nothing to return to, and the last room's mood follows them indefinitely.
Detecting Which Room The Player Is In
Roblox offers several ways to answer whether a point sits inside a room, and they differ sharply in cost and reliability. Here's how the main options compare:
| Method | Handles rotated rooms | Per-check cost | Best for |
|---|---|---|---|
| Touched / TouchEnded | Yes | Event-driven, physics-dependent | One-shot triggers, not continuous membership |
| GetPartBoundsInBox | Yes (bounding box only) | Engine query, allocates a result array | Broad checks around a known position |
| GetPartsInPart | Yes, plus meshes and unions | Highest — exact geometry | Irregular rooms such as caves or sewers |
| PointToObjectSpace math | Yes | A few arithmetic operations per zone | Client-side box zones |
Touched events are tempting because they're event-driven, but they depend on physics contact and fire inconsistently for a player who is standing still or moving fast. The spatial query APIs are precise and handle complex shapes, yet they allocate a result array on every call and require CanQuery to stay on.
For box-shaped rooms, converting the player's position into the zone's local space with CFrame:PointToObjectSpace and comparing it against half the zone's size is pure arithmetic. It handles rotated rooms correctly, allocates nothing and stays cheap at 10 checks per second even against dozens of zones.
For box-shaped rooms, transform the player's position with CFrame:PointToObjectSpace and compare it to half the zone's Size. It's allocation-free, handles rotation and is more reliable than Touched events.
The resolver walks every zone, keeps the ones containing the point and picks the highest priority, breaking ties with the smallest volume. That tie-break means a closet zone beats the warehouse around it even if nobody remembered to set a priority.
The margin parameter is the flicker fix, and we'll come back to it shortly. For now, note that the current zone gets a 2-stud grace margin while every other zone gets none.
Blending Presets With TweenService
Once you know the target room, the blender moves every property from its current value toward the preset. TweenService interpolates Color3 and number properties natively, so one tween per instance covers Lighting, Atmosphere and the color grade together:
```lua local TweenService = game:GetService('TweenService') local Lighting = game:GetService('Lighting') local atmosphere = Lighting:FindFirstChildOfClass('Atmosphere') if not atmosphere then atmosphere = Instance.new('Atmosphere') atmosphere.Parent = Lighting end local grade = Instance.new('ColorCorrectionEffect') grade.Parent = workspace.CurrentCamera local active = {} local function applyPreset(preset) for _, tween in active do tween:Cancel() end table.clear(active) local info = TweenInfo.new(preset.BlendTime or 1, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut) local targets = { [Lighting] = preset.Lighting, [atmosphere] = preset.Atmosphere, [grade] = preset.ColorCorrection, } for instance, goals in targets do local tween = TweenService:Create(instance, info, goals) table.insert(active, tween) tween:Play() end end ```Canceling active tweens before starting new ones matters more than it looks. A player who sprints through three rooms in two seconds should blend smoothly from wherever the lighting currently sits, and Tween:Cancel() leaves each property at its mid-blend value rather than snapping it back.
To blend Lighting and Atmosphere together, create one tween per instance from a shared TweenInfo and cancel the previous set first. Canceled tweens hold their mid-blend values, so rapid room changes stay smooth.
The easing curve shapes the mood as much as the values do. Enum.EasingStyle.Sine with InOut reads as natural eye adaptation, while Quint with In lets darkness creep slowly and then close fast — useful when a hallway is about to turn hostile.
Not every property can ride a tween. Booleans such as GlobalShadows and Sky textures can't be interpolated, and Lighting.Technology can't be set from a script at runtime at all.
Watch ClockTime as well: tweening from 23 to 1 sweeps backward through the entire day. Blend ExposureCompensation and Ambient instead of time of day when a room needs to feel darker.
For Sky changes, hide the swap rather than blending it. Tween Atmosphere.Density up toward 0.8 over half a second, replace the Sky's textures at the peak and tween the density back down, and the player reads it as fog rolling through.
Why Client-Side Overrides Matter
Changes a LocalScript makes to Lighting don't replicate, which is exactly what per-room atmosphere needs. Two players standing in different rooms each see their own room's mood, with no server traffic and no contention over a shared value.
Be aware that the reverse also holds: if a server Script writes to a Lighting property, that value replicates and overwrites whatever the client blended. Pick one owner per property — typically the server owns ClockTime for a shared day cycle, and the client owns everything the zones touch.
Run lighting zones in a LocalScript. Client-side Lighting changes don't replicate, so each player sees their own room's mood, but any server write to the same property will overwrite the client's blend.
Post-processing effects deserve the same treatment. A ColorCorrectionEffect, BloomEffect or BlurEffect parented to workspace.CurrentCamera renders only for that client, which keeps one player's panic blur off everyone else's screen.
Keep in mind that any client can change its own lighting freely, exploit or not. Treat lighting as presentation only — if darkness hides a key or a monster, the server still has to own that information, which our Roblox anti-exploit guide covers in depth.
Preventing Flicker At Room Boundaries
The most visible bug in a zone system is the doorway strobe: a player standing on the threshold bounces between two zones every poll, and the lighting pulses between presets. Three techniques eliminate it:
- Hysteresis margins. The current zone gets a 1-2 stud grace margin before the player counts as leaving it. Entering a room requires crossing its true boundary, while leaving requires crossing the boundary plus the margin.
- Overlap with priority. Overlap adjacent zones by a few studs inside the doorway and let Priority decide the winner. The player always has an answer, and that answer changes only once.
- Fixed polling. Checking every 0.1 seconds rather than every frame caps transitions at 10 per second and keeps CPU cost flat. At the default WalkSpeed of 16, a player moves just 1.6 studs between checks.
Together, these turn a twitchy boundary into a single clean handoff. Here's the driver that ties the resolver and blender to the player:
```lua local Players = game:GetService('Players') local RunService = game:GetService('RunService') local CollectionService = game:GetService('CollectionService') local ReplicatedStorage = game:GetService('ReplicatedStorage') local Presets = require(ReplicatedStorage.LightingPresets) local player = Players.LocalPlayer local zones = CollectionService:GetTagged('LightingZone') local currentZone = nil local accumulator = 0 CollectionService:GetInstanceAddedSignal('LightingZone'):Connect(function(zone) table.insert(zones, zone) end) CollectionService:GetInstanceRemovedSignal('LightingZone'):Connect(function(zone) local index = table.find(zones, zone) if index then table.remove(zones, index) end end) RunService.Heartbeat:Connect(function(dt) accumulator += dt if accumulator < 0.1 then return end accumulator = 0 local character = player.Character local root = character and character:FindFirstChild('HumanoidRootPart') if not root then return end local zone = resolveZone(root.Position, zones, currentZone) if zone ~= currentZone then currentZone = zone local presetName = zone and zone:GetAttribute('Preset') or 'Default' applyPreset(Presets[presetName] or Presets.Default) end end) ```Notice that the driver only calls the blender when the zone actually changes. Tweening on every poll, even toward identical values, would restart the blend clock and keep the lighting perpetually mid-transition.
We read the HumanoidRootPart rather than the camera because third-person cameras routinely clip through walls into the neighboring room. In a locked first-person horror game, the camera position works well and reacts to peeks around corners — our Roblox camera systems guide covers those setups.
Presets For Horror And Liminal Spaces
The values below are starting points for a horror basement and a liminal office. Tune them against your own geometry, because surface materials and placed light sources shift the result considerably:
| Property | Horror basement | Liminal office |
|---|---|---|
| Lighting.Ambient | 8, 8, 12 | 150, 140, 110 |
| Lighting.ExposureCompensation | -1.0 | 0.4 |
| Atmosphere.Density | 0.45 | 0.15 |
| Atmosphere.Haze | 2.5 | 0.5 |
| ColorCorrection.Saturation | -0.4 | -0.15 |
| ColorCorrection.Contrast | 0.25 | -0.05 |
| ColorCorrection.TintColor | 200, 215, 205 | 255, 244, 214 |
| Bloom.Intensity | 0.2 | 1.0 |
| Blend time | 3 s creep, 0.15 s for scares | 0.8 to 1.5 s |
Horror leans on crushed shadows and desaturation, with Ambient close to black so only placed lights read. Liminal spaces work the opposite way — flat, bright, slightly overexposed light with a fluorescent yellow tint, so nothing casts a meaningful shadow and the space feels uninhabited.
For horror, drop Ambient near black, set ExposureCompensation around -1 and desaturate by about 0.4. For liminal spaces, raise Ambient, add a warm yellow tint, push exposure slightly positive and keep shadows soft.
Blend timing carries as much weight as the values themselves. A 3-second creep into a basement feels like eyes adjusting, while a 0.15-second snap paired with a sound cue lands as a scare; liminal rooms often work best with a slow shift the player only notices afterward.
Streaming, Performance, And Testing
StreamingEnabled adds one trap: zone parts far from the player may not exist on the client yet, so the resolver can't see the room they're about to enter. Group zone parts in a Model with ModelStreamingMode set to Persistent, or store zone CFrames and sizes as data in ReplicatedStorage — our StreamingEnabled guide explains both trade-offs.
The driver above already caches the tagged list rather than calling GetTagged every poll, which avoids a fresh array allocation 10 times per second. Confirm the remaining cost in the MicroProfiler using the approach in our Roblox performance profiling guide.
Finally, because the resolver takes its zones as an argument, it's straightforward to cover with tests. Feed it positions on boundaries, inside nested rooms and outside every zone, then assert the winner — the workflow in our Roblox unit testing guide fits it directly.
Frequently Asked Questions
Can I fade the Sky between two lighting zones?
Not directly, because Sky textures swap instantly. Tween Atmosphere.Density up to around 0.8, swap the Sky at the peak, then tween density back down so the change reads as fog rolling through.
Why does FogEnd have no effect in my zone presets?
When an Atmosphere instance sits in Lighting, it overrides FogStart and FogEnd. Tween Atmosphere.Density, Haze and Color for per-room fog, or remove the Atmosphere if you want classic linear fog.
Should zone detection track the camera or the character?
Track the HumanoidRootPart in third-person games, since the camera often clips into neighboring rooms. In locked first-person horror, the camera position works and reacts to peeks around corners.
How much grace margin stops doorway lighting flicker?
A 1-2 stud hysteresis margin on the current zone, plus 0.1-second polling, removes most doorway strobing. At WalkSpeed 16 a player moves 1.6 studs per poll, so each transition fires once.
Can exploiters brighten a dark horror room?
Yes. Lighting runs on the client, so exploiters can override it at will. Keep anything darkness hides — keys, monsters, exits — server-authoritative so seeing it grants no real advantage.
Building Rooms That Feel Different
A zone system is a small amount of code with an outsized effect on how a horror or liminal world feels from one doorway to the next. Get the resolver, blender and client ownership right, and your lighting artists can tune every room from a single preset file.
If you're planning a horror or liminal experience and want a second set of eyes on the lighting architecture, the simplified.media team works through zone systems, streaming budgets and client-server ownership with Roblox developers regularly. For the wider picture of what runs where, our Roblox server architecture guide is the natural next read.


