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.

Battle royale games create tension through scarcity and inevitability. Everyone starts with nothing — random loot placement means no two matches follow the same path — and a shrinking safe zone forces players together until only one survives. This template provides the complete infrastructure: a 7-phase match lifecycle state machine, a storm system that picks random sub-circles for each phase and smoothly interpolates the zone boundary, a CollectionService-tagged loot spawner with weighted rarity selection, a kill feed and elimination tracker with placement recording, and a spectator system that assigns eliminated players a follow camera on their remaining opponents.

Core Systems

Match Lifecycle State Machine

7 phases: Lobby → Waiting → Countdown → Deploying → InProgress → Victory → Cleanup. Each phase is a self-contained handler that chains to the next automatically.

Shrinking Storm

Picks a random sub-circle inside the current safe zone for each phase. Smooth ease-in-out interpolation during the shrink period. Broadcasts to all clients via ZoneUpdate remote.

Weighted Loot Spawning

CollectionService-tagged LootSpawn parts mark all loot positions. On match start, each point rolls a rarity tier (Common–Legendary) and spawns the matching weapon model with a ProximityPrompt.

Elimination Tracker

Records every elimination with victim, killer, placement, and timestamp. Broadcasts to the kill feed. Triggers inventory drop at the victim’s death position.

Spectator System

Eliminated players are assigned a follow camera on a living player. Supports cycling through remaining players with a configurable cooldown.

Drop System

Teleports players to a sky platform at match start. Players free-fall and manually deploy a parachute. Auto-deploys below the configured threshold height.

Key Code: Match Lifecycle State Machine

The MatchManager uses a table of phase handlers that each transition to the next phase when complete. Phase transitions broadcast MatchState to all clients so HUDs can update.
--!strict
-- ServerScriptService/BattleRoyale/MatchManager.lua (phase handlers excerpt)

local handlers: { [MatchPhase]: () -> () } = {
    Waiting = function()
        self:_setPhase("Waiting")
        while self._phase == "Waiting" do
            local playerCount = #Players:GetPlayers()
            if playerCount >= Config.MIN_PLAYERS then
                self:_transitionTo("Countdown")
                return
            end
            task.wait(1)
        end
    end,

    Countdown = function()
        self:_setPhase("Countdown")
        self._countdownRemaining = Config.COUNTDOWN_DURATION

        for i = Config.COUNTDOWN_DURATION, 1, -1 do
            if self._phase ~= "Countdown" then return end
            self._countdownRemaining = i
            MatchStateRemote:FireAllClients("Countdown", i)

            -- Abort countdown if players drop below minimum
            if #Players:GetPlayers() < Config.MIN_PLAYERS then
                self:_transitionTo("Waiting")
                return
            end
            task.wait(1)
        end

        self:_transitionTo("Deploying")
    end,

    InProgress = function()
        self:_setPhase("InProgress")
        self._matchStartTime = os.clock()

        -- Start the storm system
        StormSystem:Start()

        -- Main match loop: check for winner
        while self._phase == "InProgress" do
            local aliveCount = self:GetAliveCount()

            if aliveCount <= 1 then
                local alivePlayers = self:GetAlivePlayers()
                if #alivePlayers == 1 then
                    self._winner = alivePlayers[1]
                end
                self:_transitionTo("Victory")
                return
            end

            -- Tick storm system
            StormSystem:Tick()

            task.wait(0.5)
        end
    end,
}

Key Code: Shrinking Zone System

The storm system picks a new random sub-circle for each phase that fits inside the current safe area, then smoothly interpolates the current zone toward it over the shrink duration. A smooth ease-in-out function prevents jarring movement.
--!strict
-- ServerScriptService/BattleRoyale/StormSystem.lua

-- Pick a random point inside the current zone for the next smaller zone center
function StormSystem:_pickNextCenter(currentZone: ZoneState, nextRadius: number): (number, number)
    local maxOffset = math.max(0, currentZone.radius - nextRadius)

    if maxOffset <= 0 then
        return currentZone.centerX, currentZone.centerZ
    end

    local angle = math.random() * 2 * math.pi
    local distance = math.random() * maxOffset
    local newCenterX = currentZone.centerX + math.cos(angle) * distance
    local newCenterZ = currentZone.centerZ + math.sin(angle) * distance

    return newCenterX, newCenterZ
end

-- Shrink phase: interpolate current zone toward target with ease-in-out
function StormSystem:_runPhase(phaseConfig: { waitTime: number, shrinkTime: number, radiusFraction: number })
    local nextRadius = Config.ZONE_INITIAL_RADIUS * phaseConfig.radiusFraction
    local nextCenterX, nextCenterZ = self:_pickNextCenter(self._currentZone, nextRadius)

    self._targetZone = {
        centerX = nextCenterX,
        centerZ = nextCenterZ,
        radius = nextRadius,
    }

    -- Wait period: zone is static, target shown on minimap
    self._isShrinking = false
    self:_broadcastZone()

    local waitElapsed = 0
    while waitElapsed < phaseConfig.waitTime and self._running do
        task.wait(1)
        waitElapsed += 1
    end

    if not self._running then return end

    -- Shrink period: interpolate current zone toward target
    self._isShrinking = true
    self:_broadcastZone()

    local startZone: ZoneState = {
        centerX = self._currentZone.centerX,
        centerZ = self._currentZone.centerZ,
        radius = self._currentZone.radius,
    }

    local shrinkElapsed = 0
    while shrinkElapsed < phaseConfig.shrinkTime and self._running do
        local dt = task.wait()
        shrinkElapsed += dt

        local alpha = math.clamp(shrinkElapsed / phaseConfig.shrinkTime, 0, 1)
        -- Smooth ease-in-out interpolation
        local smoothAlpha = alpha * alpha * (3 - 2 * alpha)

        self._currentZone.centerX = startZone.centerX + (self._targetZone.centerX - startZone.centerX) * smoothAlpha
        self._currentZone.centerZ = startZone.centerZ + (self._targetZone.centerZ - startZone.centerZ) * smoothAlpha
        self._currentZone.radius = startZone.radius + (self._targetZone.radius - startZone.radius) * smoothAlpha

        self:_broadcastZone()
    end

    -- Snap to target at end of shrink
    self._currentZone.centerX = self._targetZone.centerX
    self._currentZone.centerZ = self._targetZone.centerZ
    self._currentZone.radius = self._targetZone.radius
    self._isShrinking = false
    self:_broadcastZone()
end

Key Code: Weighted Loot Spawning

All LootSpawn-tagged BasePart instances in the workspace receive a weapon on match start. Rarity is rolled with weighted random selection, then a matching weapon definition is chosen at random from that tier.
--!strict
-- ServerScriptService/BattleRoyale/LootSpawner.lua

function LootSpawner:SpawnAllLoot()
    self:DespawnAllLoot()

    local spawnPoints = CollectionService:GetTagged(Config.LOOT_SPAWN_TAG)
    print(`[LootSpawner] Spawning loot at {#spawnPoints} points`)

    for _, spawnPoint in spawnPoints do
        if not spawnPoint:IsA("BasePart") then continue end

        local rarity = self:_rollRarity()
        local weaponName = self:_pickWeapon(rarity)
        if not weaponName then continue end

        -- Clone weapon model from ServerStorage
        local weaponTemplate = ServerStorage:FindFirstChild("Weapons")
            and ServerStorage.Weapons:FindFirstChild(weaponName)
        if not weaponTemplate then continue end

        local lootItem = weaponTemplate:Clone()
        lootItem:PivotTo(spawnPoint.CFrame + Vector3.new(0, 1, 0))

        -- Attach rarity metadata
        local rarityValue = Instance.new("StringValue")
        rarityValue.Name = "Rarity"
        rarityValue.Value = rarity
        rarityValue.Parent = lootItem

        -- Add proximity prompt for pickup
        local prompt = Instance.new("ProximityPrompt")
        prompt.ActionText = "Pick Up"
        prompt.ObjectText = `{weaponName} ({rarity})`
        prompt.HoldDuration = 0.15
        prompt.MaxActivationDistance = 8
        prompt.Parent = lootItem.PrimaryPart or lootItem:FindFirstChildWhichIsA("BasePart")

        lootItem.Parent = workspace.LootFolder
        table.insert(spawnedLoot, lootItem)
    end

    print(`[LootSpawner] Spawned {#spawnedLoot} items`)
end

Key Code: Elimination Tracking

The EliminationTracker records every kill with full context, updates placement order, broadcasts to the kill feed, and drops the victim’s inventory at their death position.
--!strict
-- ServerScriptService/BattleRoyale/EliminationTracker.lua

function EliminationTracker:RecordElimination(victim: Player, killer: Player?, placement: number)
    -- Track placement
    placements[victim] = placement

    -- Track kill count for the killer
    if killer and killer ~= victim then
        killCounts[killer] = (killCounts[killer] or 0) + 1
    end

    -- Log the elimination
    local record: EliminationRecord = {
        victim = victim.Name,
        killer = if killer and killer ~= victim then killer.Name else nil,
        placement = placement,
        timestamp = os.clock(),
    }
    table.insert(eliminations, record)

    -- Broadcast kill feed to all clients
    local killerName = record.killer or "The Storm"
    KillFeedRemote:FireAllClients(killerName, victim.Name, placement)

    -- Drop victim's inventory at their death location
    self:_dropInventory(victim)
end

Architecture

ScriptPurpose
MatchManager.luau7-phase state machine, alive player tracking, winner detection, disconnect handling
StormSystem.luauZone phases, random sub-circle selection, smooth shrink interpolation, storm damage ticks
LootSpawner.luauWeighted rarity rolls, weapon cloning onto tagged spawn points, despawn on cleanup
EliminationTracker.luauKill credit, placement recording, kill feed broadcasts, inventory drops
DropSystem.luauSky teleport, free-fall physics, parachute deploy at threshold height
SpectatorSystem.luauFollow-camera assignment on elimination, player cycling with cooldown
WeaponManager.luauDamage calculation per weapon def, rarity stat scaling, server-side hit validation
InventoryManager.luauPer-match loadout, pickup/drop, ammo tracking — no persistence across matches

Zone Phases

PhaseWait TimeShrink TimeFinal Radius Fraction
160s30s60% of initial
245s25s35% of initial
330s20s15% of initial
420s15s5% of initial
510s10s0% (fully closed)
Storm damage scales by phase: baseDamage × phaseIndex per tick, so late-game storm is lethal.

Weapon Rarity Tiers

RarityWeightAR DamageSniper DamageDrop Rate
Common401840%
Uncommon302130%
Rare18239018%
Epic9271059%
Legendary3321303%
One life per match. Battle royale requires no server-side respawn. Set Players.RespawnTime to a very large number (math.huge) and handle character recreation manually only during Cleanup → Lobby transition. Eliminating a player via humanoid.Health = 0 while respawn is disabled will keep the character in place for the inventory drop, then the SpectatorSystem takes over.

How to Use This Template

Build me a 50-player battle royale on a tropical island map. Use the standard
5-zone phases. Add an airdrop system that drops a supply crate with legendary
loot every 3 minutes during InProgress. Include squad mode with teams of 2.
See the New Game workflow for the complete 8-step build process.

Build docs developers (and LLMs) love