Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/brockmartin/roblox-game-skill/llms.txt

Use this file to discover all available pages before exploring further.

Horror games succeed or fail on atmosphere before anything else. The player must feel uneasy before any monster appears — dread built from darkness, fog, ambient sound, and environmental storytelling. When the scare finally comes, it lands harder because of the tension that preceded it. This template is a complete Doors-style horror engine: a client-side atmosphere system with four mood presets (Safe, Uneasy, Danger, Panic) and smooth tween transitions, a server-authoritative five-state monster AI finite state machine with pathfinding and line-of-sight detection, a proximity/progress-triggered event sequencer for scripted horror moments, and a jumpscare system that takes camera control, plays a sound stinger, and then executes the kill.

Core Systems

Atmosphere System

Four Lighting presets with smooth TweenService transitions. Controls ambient brightness, fog density, color correction saturation, and atmosphere haze. Server-driven via AtmosphereRemote.

Monster AI (5-State FSM)

Dormant → Patrol → Alert → Chase → Kill. Line-of-sight raycasts, hearing range detection, hiding spot checking, PathfindingService navigation, and configurable speeds per state.

Event Sequencer

Parts tagged HorrorTrigger in the workspace fire sequences of actions (Flicker, DoorSlam, ShadowAppear, SoundPlay, AtmosphereChange) with per-action delays when a player enters.

Jumpscare System

Server fires JumpscareRemote on kill. Client takes camera control, plays a monster-specific sound stinger and screen flash, then yields control back. Kill is applied 1.5 seconds later.

Flashlight Controller

Client-side SpotLight attachment with battery drain, battery pickup interaction, and toggle. Low-battery flicker effect as battery approaches zero.

Door & Key System

Locked doors require specific key items in the player’s inventory to open. Keys are placed as world items. Server validates all unlock requests.

Key Code: Atmosphere System

The atmosphere controller defines four presets with full Lighting, ColorCorrectionEffect, and Atmosphere property sets. All transitions use TweenService for smooth mood changes. It also exposes a Flicker utility for rapid panic flashes.
-- AtmosphereController.luau (StarterPlayerScripts)

local PRESETS = {
    Safe = {
        Ambient = Color3.fromRGB(80, 80, 90),
        Brightness = 1.2,
        FogEnd = 500,
        FogColor = Color3.fromRGB(30, 30, 40),
        ColorCorrection = {
            Brightness = 0, Contrast = 0.05,
            Saturation = -0.1, TintColor = Color3.fromRGB(255, 245, 240),
        },
    },
    Uneasy = {
        Ambient = Color3.fromRGB(40, 40, 55),
        Brightness = 0.6,
        FogEnd = 250,
        FogColor = Color3.fromRGB(15, 15, 25),
        ColorCorrection = {
            Brightness = -0.05, Contrast = 0.15,
            Saturation = -0.3, TintColor = Color3.fromRGB(220, 220, 240),
        },
    },
    Danger = {
        Ambient = Color3.fromRGB(15, 10, 20),
        Brightness = 0.1,
        FogEnd = 80,
        FogColor = Color3.fromRGB(5, 5, 10),
        ColorCorrection = {
            Brightness = -0.12, Contrast = 0.3,
            Saturation = -0.5, TintColor = Color3.fromRGB(200, 180, 200),
        },
    },
    Panic = {
        Ambient = Color3.fromRGB(5, 0, 0),
        Brightness = 0.02,
        FogEnd = 40,
        FogColor = Color3.fromRGB(2, 0, 0),
        ColorCorrection = {
            Brightness = -0.2, Contrast = 0.4,
            Saturation = -0.7, TintColor = Color3.fromRGB(180, 140, 140),
        },
    },
}

-- Flicker effect: rapid toggling between two presets
function AtmosphereController.Flicker(flickerCount: number, intervalSeconds: number)
    task.spawn(function()
        local originalPreset = currentPreset
        for i = 1, flickerCount do
            AtmosphereController.SetPreset("Panic", 0.05)
            task.wait(intervalSeconds * 0.3)
            AtmosphereController.SetPreset(originalPreset, 0.05)
            task.wait(intervalSeconds * 0.7)
        end
    end)
end

Key Code: Monster AI State Machine

The monster FSM runs at 10 Hz via task.wait(0.1). Each state (Dormant, Patrol, Alert, Chase, Kill) has an entry handler that sets walk speed and a per-tick update handler. State transitions fire MonsterRemote so clients can react with animations and atmosphere changes.
-- MonsterAI.luau (ServerScriptService)

-- Transition to a new state
function MonsterAI:SetState(newState: MonsterState)
    if self.State == newState then return end

    local oldState = self.State
    self.State = newState

    -- State entry logic
    if newState == "Dormant" then
        self.Humanoid.WalkSpeed = 0
        self.Target = nil
    elseif newState == "Patrol" then
        self.Humanoid.WalkSpeed = self.Config.PatrolSpeed
        self.Target = nil
        self.AlertTimer = 0
    elseif newState == "Alert" then
        self.Humanoid.WalkSpeed = self.Config.PatrolSpeed * 0.7
        self.AlertTimer = self.Config.AlertDuration
    elseif newState == "Chase" then
        self.Humanoid.WalkSpeed = self.Config.ChaseSpeed
    elseif newState == "Kill" then
        self.Humanoid.WalkSpeed = 0
    end

    -- Fire event for client-side reactions (animation, sound, atmosphere)
    local remotes = game.ReplicatedStorage:FindFirstChild("HorrorShared")
    if remotes then
        local monsterRemote = remotes.Remotes:FindFirstChild("MonsterRemote") :: RemoteEvent?
        if monsterRemote then
            monsterRemote:FireAllClients(self.Model.Name, newState, oldState)
        end
    end
end

-- Execute the kill: trigger jumpscare, then kill after delay
function MonsterAI:ExecuteKill(player: Player)
    local remotes = game.ReplicatedStorage:FindFirstChild("HorrorShared")
    if remotes then
        local jumpscareRemote = remotes.Remotes:FindFirstChild("JumpscareRemote") :: RemoteEvent?
        if jumpscareRemote then
            jumpscareRemote:FireClient(player, self.Model.Name)
        end
    end

    -- Wait for jumpscare to play, then kill
    task.delay(1.5, function()
        local character = player.Character
        if character then
            local humanoid = character:FindFirstChildOfClass("Humanoid")
            if humanoid then
                humanoid.Health = 0
            end
        end

        -- Return to patrol after kill
        task.delay(2, function()
            self:SetState("Patrol")
        end)
    end)
end

Key Code: Detection Logic

The monster uses both hearing (range-based) and sight (raycast + field-of-view cone) to detect players. Running players have 1.5× the effective hearing range. Players inside HidingSpot-tagged parts are excluded from detection.
-- MonsterAI.luau — DetectPlayer and HasLineOfSight

function MonsterAI:DetectPlayer(): Player?
    local nearest: Player? = nil
    local nearestDist = math.huge

    for _, player in Players:GetPlayers() do
        local playerPos = self:GetPlayerPosition(player)
        if not playerPos then continue end

        -- Skip players who are hiding
        if self:IsPlayerHiding(player) then continue end

        local dist = (self.RootPart.Position - playerPos).Magnitude

        local isRunning = self:IsPlayerRunning(player)
        local effectiveHearingRange = if isRunning
            then self.Config.HearingRange * 1.5
            else self.Config.HearingRange

        local detected = false

        if dist <= effectiveHearingRange then
            detected = true
        elseif dist <= self.Config.SightRange
            and self:IsInFieldOfView(playerPos)
            and self:HasLineOfSight(playerPos) then
            detected = true
        end

        if detected and dist < nearestDist then
            nearest = player
            nearestDist = dist
        end
    end

    return nearest
end

-- Raycast-based line of sight check
function MonsterAI:HasLineOfSight(targetPosition: Vector3): boolean
    local origin = self.RootPart.Position + Vector3.new(0, 2, 0)
    local direction = targetPosition - origin

    local rayParams = RaycastParams.new()
    rayParams.FilterDescendantsInstances = { self.Model }
    rayParams.FilterType = Enum.RaycastFilterType.Exclude

    local result = workspace:Raycast(origin, direction, rayParams)

    if not result then return true end

    local hitModel = result.Instance:FindFirstAncestorOfClass("Model")
    if hitModel then
        local hitPlayer = Players:GetPlayerFromCharacter(hitModel)
        if hitPlayer then return true end
    end

    return false
end

Architecture

ScriptPurpose
MonsterAI.luau5-state FSM, pathfinding, sight/hearing detection, hiding spot checks, kill execution
EventSequencer.luauRegisters HorrorTrigger-tagged parts, fires action sequences on proximity
DoorKeySystem.luauKey item inventory, locked door validation, unlock logic
ProgressionManager.luauRoom/level advancement, difficulty scaling per room number
GameManager.luauRound lifecycle, player spawning, win/loss conditions

Monster FSM Reference

StateWalk SpeedEntry ConditionExit Condition
Dormant0Initial state, or activated by EventSequencerEventSequencer fires activation trigger
PatrolPatrolSpeed (default: 10)After activation, after losing a targetPlayer detected (sight or hearing)
AlertPatrolSpeed × 0.7Target lost, investigating last known positionPlayer re-detected → Chase; Timer expires → Patrol
ChaseChaseSpeed (default: 22, > player’s 16)Player detectedPlayer hides → search; Player out of sight → Alert; Within kill range → Kill
Kill0Within KillRange studs of targetExecuteKill fires jumpscare; returns to Patrol after 2s delay
Pacing is everything. Never let the monster be active for more than 60–90 seconds continuously without a rest period. Long quiet stretches before each monster encounter make jump scares dramatically more effective. Use the EventSequencer to control when MonsterAI:Start() is called.

How to Use This Template

Build me a backrooms horror game. Players navigate procedurally connected rooms
with fluorescent lights that flicker. The monster is called "The Entity" — it's
invisible until within 20 studs. Add a sanity meter that drains in darkness and
refills near light sources.
See the New Game workflow for the complete 8-step build process.

Build docs developers (and LLMs) love