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.
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.
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,}
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 centerfunction 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, newCenterZend-- Shrink phase: interpolate current zone toward target with ease-in-outfunction 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
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.luafunction 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
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.luafunction 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
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.
Build me a 50-player battle royale on a tropical island map. Use the standard5-zone phases. Add an airdrop system that drops a supply crate with legendaryloot every 3 minutes during InProgress. Include squad mode with teams of 2.