game-loop
SKILL.md
Roblox Game Loop Systems
When implementing round-based multiplayer games, follow these patterns for smooth game flow.
Game State Machine
Core States
local GameState = {
WAITING = "Waiting", -- In lobby, waiting for players
STARTING = "Starting", -- Countdown before game starts
PLAYING = "Playing", -- Game in progress
INTERMISSION = "Intermission", -- Between rounds, showing results
ENDING = "Ending" -- Game ending, cleanup
}
State Manager
local GameManager = {}
GameManager.currentState = GameState.WAITING
GameManager.stateStartTime = 0
GameManager.round = 0
local StateChangedEvent = Instance.new("BindableEvent")
GameManager.StateChanged = StateChangedEvent.Event
function GameManager.setState(newState)
local oldState = GameManager.currentState
GameManager.currentState = newState
GameManager.stateStartTime = os.clock()
StateChangedEvent:Fire(newState, oldState)
-- Notify clients
GameStateRemote:FireAllClients(newState, {
round = GameManager.round,
timestamp = workspace:GetServerTimeNow()
})
end
function GameManager.getTimeInState()
return os.clock() - GameManager.stateStartTime
end
Main Game Loop
local MIN_PLAYERS = 2
local COUNTDOWN_TIME = 10
local ROUND_TIME = 180
local INTERMISSION_TIME = 15
local function gameLoop()
while true do
-- WAITING STATE
GameManager.setState(GameState.WAITING)
while #Players:GetPlayers() < MIN_PLAYERS do
StatusRemote:FireAllClients("Waiting for players...",
MIN_PLAYERS - #Players:GetPlayers() .. " more needed")
task.wait(1)
end
-- STARTING STATE (Countdown)
GameManager.setState(GameState.STARTING)
for i = COUNTDOWN_TIME, 1, -1 do
StatusRemote:FireAllClients("Game starting in...", i)
task.wait(1)
-- Cancel if players left
if #Players:GetPlayers() < MIN_PLAYERS then
break
end
end
if #Players:GetPlayers() < MIN_PLAYERS then
continue
end
-- PLAYING STATE
GameManager.round = GameManager.round + 1
GameManager.setState(GameState.PLAYING)
setupRound()
local roundStartTime = os.clock()
while os.clock() - roundStartTime < ROUND_TIME do
-- Check win conditions
local winner = checkWinCondition()
if winner then
break
end
task.wait(0.5)
end
-- INTERMISSION STATE
GameManager.setState(GameState.INTERMISSION)
local results = calculateResults()
ResultsRemote:FireAllClients(results)
cleanupRound()
task.wait(INTERMISSION_TIME)
end
end
task.spawn(gameLoop)
Lobby System
Lobby Manager
local LobbyManager = {}
LobbyManager.lobbySpawn = workspace:WaitForChild("LobbySpawn")
LobbyManager.playersInLobby = {}
function LobbyManager.teleportToLobby(player)
local character = player.Character
if not character then return end
character:PivotTo(LobbyManager.lobbySpawn.CFrame + Vector3.new(
math.random(-10, 10), 5, math.random(-10, 10)
))
LobbyManager.playersInLobby[player] = true
-- Enable lobby features
player:SetAttribute("InLobby", true)
end
function LobbyManager.teleportToGame(player, spawnPoint)
local character = player.Character
if not character then return end
character:PivotTo(spawnPoint.CFrame)
LobbyManager.playersInLobby[player] = nil
player:SetAttribute("InLobby", false)
end
function LobbyManager.teleportAllToGame(spawnPoints)
local players = Players:GetPlayers()
for i, player in ipairs(players) do
local spawnIndex = ((i - 1) % #spawnPoints) + 1
LobbyManager.teleportToGame(player, spawnPoints[spawnIndex])
end
end
Lobby Obby (Mini-game while waiting)
local function setupLobbyObby()
local obbyStart = workspace.Lobby.ObbyStart
local obbyEnd = workspace.Lobby.ObbyEnd
-- Teleport to start on touch
obbyStart.Touched:Connect(function(hit)
local player = Players:GetPlayerFromCharacter(hit.Parent)
if player and player:GetAttribute("InLobby") then
hit.Parent:PivotTo(obbyStart.CFrame + Vector3.new(0, 3, 0))
end
end)
-- Reward on completion
obbyEnd.Touched:Connect(function(hit)
local player = Players:GetPlayerFromCharacter(hit.Parent)
if player and player:GetAttribute("InLobby") then
local completions = player:GetAttribute("LobbyObbyCompletions") or 0
player:SetAttribute("LobbyObbyCompletions", completions + 1)
-- Small reward
DataManager.addCurrency(player, "coins", 10)
-- Teleport back to start
hit.Parent:PivotTo(obbyStart.CFrame + Vector3.new(0, 3, 0))
end
end)
end
Voting System
Map Voting
local VotingManager = {}
VotingManager.maps = {"Desert", "Forest", "City", "Snow"}
VotingManager.votes = {}
VotingManager.options = {}
function VotingManager.startVote(numOptions)
numOptions = numOptions or 3
VotingManager.votes = {}
VotingManager.options = {}
-- Select random maps for voting
local available = table.clone(VotingManager.maps)
for i = 1, math.min(numOptions, #available) do
local index = math.random(#available)
table.insert(VotingManager.options, available[index])
table.remove(available, index)
end
-- Notify clients
VoteStartRemote:FireAllClients(VotingManager.options)
end
function VotingManager.castVote(player, optionIndex)
if optionIndex < 1 or optionIndex > #VotingManager.options then
return false
end
VotingManager.votes[player.UserId] = optionIndex
-- Broadcast updated vote counts
local counts = {}
for i = 1, #VotingManager.options do
counts[i] = 0
end
for _, vote in pairs(VotingManager.votes) do
counts[vote] = counts[vote] + 1
end
VoteUpdateRemote:FireAllClients(counts)
return true
end
function VotingManager.getWinner()
local counts = {}
for i = 1, #VotingManager.options do
counts[i] = 0
end
for _, vote in pairs(VotingManager.votes) do
counts[vote] = counts[vote] + 1
end
-- Find highest vote (random tiebreaker)
local maxVotes = 0
local winners = {}
for i, count in ipairs(counts) do
if count > maxVotes then
maxVotes = count
winners = {i}
elseif count == maxVotes then
table.insert(winners, i)
end
end
local winnerIndex = winners[math.random(#winners)]
return VotingManager.options[winnerIndex]
end
-- Client voting pad interaction
VotePadRemote.OnServerEvent:Connect(function(player, padIndex)
VotingManager.castVote(player, padIndex)
end)
Physical Voting Pads
local function createVotingPads(position, options)
local pads = Instance.new("Model")
pads.Name = "VotingPads"
for i, option in ipairs(options) do
local pad = Instance.new("Part")
pad.Size = Vector3.new(8, 1, 8)
pad.Position = position + Vector3.new((i - 1) * 12 - (#options - 1) * 6, 0, 0)
pad.Anchored = true
pad.Material = Enum.Material.Neon
pad.Color = Color3.fromHSV((i - 1) / #options, 0.8, 0.9)
pad.Name = "VotePad_" .. i
pad.Parent = pads
-- Label
local billboard = Instance.new("BillboardGui")
billboard.Size = UDim2.new(0, 200, 0, 50)
billboard.StudsOffset = Vector3.new(0, 5, 0)
billboard.Parent = pad
local label = Instance.new("TextLabel")
label.Size = UDim2.new(1, 0, 1, 0)
label.BackgroundTransparency = 1
label.Text = option .. "\n0 votes"
label.TextColor3 = Color3.new(1, 1, 1)
label.TextScaled = true
label.Parent = billboard
-- Vote on touch
pad.Touched:Connect(function(hit)
local player = Players:GetPlayerFromCharacter(hit.Parent)
if player then
VotePadRemote:FireServer(i)
end
end)
end
pads.Parent = workspace.Lobby
return pads
end
Ready-Up System
local ReadyManager = {}
ReadyManager.readyPlayers = {}
function ReadyManager.setReady(player, isReady)
ReadyManager.readyPlayers[player.UserId] = isReady
-- Update UI for all players
ReadyUpdateRemote:FireAllClients(ReadyManager.getReadyStatus())
end
function ReadyManager.getReadyStatus()
local status = {}
for _, player in ipairs(Players:GetPlayers()) do
status[player.UserId] = ReadyManager.readyPlayers[player.UserId] or false
end
return status
end
function ReadyManager.allReady()
local players = Players:GetPlayers()
if #players < MIN_PLAYERS then return false end
for _, player in ipairs(players) do
if not ReadyManager.readyPlayers[player.UserId] then
return false
end
end
return true
end
function ReadyManager.reset()
ReadyManager.readyPlayers = {}
ReadyUpdateRemote:FireAllClients({})
end
-- Modified game loop with ready-up
local function gameLoopWithReady()
while true do
GameManager.setState(GameState.WAITING)
ReadyManager.reset()
-- Wait for enough players AND all ready
while not ReadyManager.allReady() do
local ready = 0
local total = #Players:GetPlayers()
for _, isReady in pairs(ReadyManager.readyPlayers) do
if isReady then ready = ready + 1 end
end
StatusRemote:FireAllClients("Ready up!", ready .. "/" .. total .. " ready")
task.wait(0.5)
end
-- Continue with game...
end
end
Spectator System
local SpectatorManager = {}
SpectatorManager.spectators = {}
SpectatorManager.targets = {}
function SpectatorManager.makeSpectator(player)
local character = player.Character
if character then
-- Make invisible and non-collidable
for _, part in ipairs(character:GetDescendants()) do
if part:IsA("BasePart") then
part.Transparency = 1
part.CanCollide = false
end
end
local humanoid = character:FindFirstChildOfClass("Humanoid")
if humanoid then
humanoid.WalkSpeed = 32 -- Faster movement
end
end
SpectatorManager.spectators[player] = true
player:SetAttribute("IsSpectator", true)
-- Start spectating a random alive player
SpectatorManager.spectateNext(player)
end
function SpectatorManager.removeSpectator(player)
SpectatorManager.spectators[player] = nil
SpectatorManager.targets[player] = nil
player:SetAttribute("IsSpectator", false)
end
function SpectatorManager.getAlivePlayers()
local alive = {}
for _, player in ipairs(Players:GetPlayers()) do
if not SpectatorManager.spectators[player] and
player.Character and
player.Character:FindFirstChildOfClass("Humanoid") and
player.Character:FindFirstChildOfClass("Humanoid").Health > 0 then
table.insert(alive, player)
end
end
return alive
end
function SpectatorManager.spectateNext(player)
local alive = SpectatorManager.getAlivePlayers()
if #alive == 0 then return end
local currentIndex = table.find(alive, SpectatorManager.targets[player]) or 0
local nextIndex = (currentIndex % #alive) + 1
SpectatorManager.targets[player] = alive[nextIndex]
SpectateTargetRemote:FireClient(player, alive[nextIndex])
end
function SpectatorManager.spectatePrevious(player)
local alive = SpectatorManager.getAlivePlayers()
if #alive == 0 then return end
local currentIndex = table.find(alive, SpectatorManager.targets[player]) or 2
local prevIndex = ((currentIndex - 2) % #alive) + 1
SpectatorManager.targets[player] = alive[prevIndex]
SpectateTargetRemote:FireClient(player, alive[prevIndex])
end
-- Client-side spectator camera
-- In LocalScript:
local spectatingTarget = nil
SpectateTargetRemote.OnClientEvent:Connect(function(target)
spectatingTarget = target
end)
RunService.RenderStepped:Connect(function()
if not LocalPlayer:GetAttribute("IsSpectator") then return end
if not spectatingTarget or not spectatingTarget.Character then return end
local targetHead = spectatingTarget.Character:FindFirstChild("Head")
if targetHead then
-- Third-person view of target
Camera.CameraType = Enum.CameraType.Custom
Camera.CameraSubject = targetHead
end
end)
Team Assignment
local TeamManager = {}
function TeamManager.assignTeams(mode)
local players = Players:GetPlayers()
if mode == "FFA" then
-- Free for all - no teams
for _, player in ipairs(players) do
player.Team = nil
player.Neutral = true
end
elseif mode == "TwoTeams" then
-- Split into two teams
local shuffled = table.clone(players)
for i = #shuffled, 2, -1 do
local j = math.random(i)
shuffled[i], shuffled[j] = shuffled[j], shuffled[i]
end
local half = math.ceil(#shuffled / 2)
for i, player in ipairs(shuffled) do
if i <= half then
player.Team = Teams.Red
else
player.Team = Teams.Blue
end
end
elseif mode == "OneVsAll" then
-- One special player vs everyone else
local special = players[math.random(#players)]
for _, player in ipairs(players) do
if player == special then
player.Team = Teams.Special
else
player.Team = Teams.Normal
end
end
return special -- Return the special player
end
end
function TeamManager.balanceTeams()
local teamCounts = {}
for _, team in ipairs(Teams:GetTeams()) do
teamCounts[team] = #team:GetPlayers()
end
-- Find imbalanced teams
local maxTeam, minTeam
local maxCount, minCount = 0, math.huge
for team, count in pairs(teamCounts) do
if count > maxCount then
maxCount = count
maxTeam = team
end
if count < minCount then
minCount = count
minTeam = team
end
end
-- Move player if difference > 1
if maxCount - minCount > 1 then
local playersOnMax = maxTeam:GetPlayers()
local toMove = playersOnMax[math.random(#playersOnMax)]
toMove.Team = minTeam
TeamChangeRemote:FireClient(toMove, minTeam.Name)
end
end
Late Join Handling
local function handleLateJoin(player)
local state = GameManager.currentState
if state == GameState.WAITING or state == GameState.INTERMISSION then
-- Can join normally
LobbyManager.teleportToLobby(player)
elseif state == GameState.STARTING then
-- Countdown - join the round
LobbyManager.teleportToLobby(player)
-- Will be teleported with everyone when round starts
elseif state == GameState.PLAYING then
-- Game in progress - spectate or join mid-round
if ALLOW_MID_ROUND_JOIN then
-- Join the round
TeamManager.assignToSmallestTeam(player)
local spawn = getTeamSpawn(player.Team)
LobbyManager.teleportToGame(player, spawn)
else
-- Force spectate
LobbyManager.teleportToLobby(player)
player:SetAttribute("JoinedLate", true)
task.wait(2) -- Wait for character
SpectatorManager.makeSpectator(player)
StatusRemote:FireClient(player, "Round in progress", "You'll join next round")
end
end
end
Players.PlayerAdded:Connect(function(player)
player.CharacterAdded:Connect(function()
handleLateJoin(player)
end)
end)
Round Setup & Cleanup
local function setupRound()
-- Select map
local selectedMap = VotingManager.getWinner()
loadMap(selectedMap)
-- Assign teams
local specialPlayer = TeamManager.assignTeams(currentGameMode)
-- Get spawn points
local spawnPoints = workspace.CurrentMap.Spawns:GetChildren()
-- Teleport players
LobbyManager.teleportAllToGame(spawnPoints)
-- Give loadouts
for _, player in ipairs(Players:GetPlayers()) do
giveLoadout(player, player.Team)
end
-- Initialize round-specific systems
initializeObjectives()
resetScores()
-- Notify clients
RoundStartRemote:FireAllClients({
map = selectedMap,
mode = currentGameMode,
timeLimit = ROUND_TIME,
specialPlayer = specialPlayer and specialPlayer.UserId
})
end
local function cleanupRound()
-- Return all players to lobby
for _, player in ipairs(Players:GetPlayers()) do
LobbyManager.teleportToLobby(player)
-- Reset player state
player:SetAttribute("IsSpectator", false)
player:SetAttribute("JoinedLate", false)
-- Reset character
if player.Character then
local humanoid = player.Character:FindFirstChildOfClass("Humanoid")
if humanoid then
humanoid.Health = humanoid.MaxHealth
end
end
-- Clear inventory
clearTemporaryItems(player)
end
-- Unload map
if workspace:FindFirstChild("CurrentMap") then
workspace.CurrentMap:Destroy()
end
-- Reset managers
SpectatorManager.spectators = {}
SpectatorManager.targets = {}
VotingManager.votes = {}
end
Win Conditions
local function checkWinCondition()
local mode = currentGameMode
if mode == "Elimination" then
-- Last player/team alive wins
local alive = SpectatorManager.getAlivePlayers()
if #alive <= 1 then
return alive[1] -- Winner (or nil if draw)
end
-- Check team elimination
local teamsAlive = {}
for _, player in ipairs(alive) do
teamsAlive[player.Team] = true
end
local count = 0
local lastTeam
for team in pairs(teamsAlive) do
count = count + 1
lastTeam = team
end
if count == 1 then
return lastTeam -- Winning team
end
elseif mode == "ScoreLimit" then
-- First to reach score wins
for _, player in ipairs(Players:GetPlayers()) do
if (player:GetAttribute("RoundScore") or 0) >= SCORE_LIMIT then
return player
end
end
elseif mode == "KingOfTheHill" then
-- Control objective for duration
local controller = getObjectiveController()
if controller then
local controlTime = controller:GetAttribute("ControlTime") or 0
if controlTime >= CONTROL_DURATION then
return controller
end
end
end
return nil -- No winner yet
end
Results Calculation
local function calculateResults()
local results = {
winner = nil,
mvp = nil,
players = {}
}
-- Determine winner
results.winner = checkWinCondition()
-- Calculate player stats
local highestScore = 0
for _, player in ipairs(Players:GetPlayers()) do
local stats = {
kills = player:GetAttribute("RoundKills") or 0,
deaths = player:GetAttribute("RoundDeaths") or 0,
score = player:GetAttribute("RoundScore") or 0,
damage = player:GetAttribute("RoundDamage") or 0,
objectives = player:GetAttribute("RoundObjectives") or 0
}
results.players[player.UserId] = stats
-- MVP is highest score
if stats.score > highestScore then
highestScore = stats.score
results.mvp = player.UserId
end
-- Grant rewards
local baseReward = 50
local winBonus = (player == results.winner or player.Team == results.winner) and 100 or 0
local mvpBonus = (player.UserId == results.mvp) and 50 or 0
local totalReward = baseReward + winBonus + mvpBonus + (stats.kills * 10)
DataManager.addCurrency(player, "coins", totalReward)
-- Grant XP
LevelingService.addExperience(player, stats.score + baseReward)
end
return results
end
Complete Integration Example
-- Main server script for round-based game
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
local Teams = game:GetService("Teams")
-- Configuration
local CONFIG = {
MIN_PLAYERS = 2,
MAX_PLAYERS = 16,
COUNTDOWN_TIME = 15,
ROUND_TIME = 300,
INTERMISSION_TIME = 20,
ALLOW_MID_ROUND_JOIN = false,
GAME_MODES = {"Elimination", "TeamDeathmatch", "FreeForAll"}
}
-- Initialize managers
local GameManager = require(script.GameManager)
local LobbyManager = require(script.LobbyManager)
local VotingManager = require(script.VotingManager)
local SpectatorManager = require(script.SpectatorManager)
local TeamManager = require(script.TeamManager)
-- Create remotes
local Remotes = Instance.new("Folder")
Remotes.Name = "GameRemotes"
Remotes.Parent = ReplicatedStorage
local GameStateRemote = Instance.new("RemoteEvent", Remotes)
GameStateRemote.Name = "GameState"
local VoteRemote = Instance.new("RemoteEvent", Remotes)
VoteRemote.Name = "Vote"
local ReadyRemote = Instance.new("RemoteEvent", Remotes)
ReadyRemote.Name = "Ready"
local SpectateRemote = Instance.new("RemoteEvent", Remotes)
SpectateRemote.Name = "Spectate"
-- Main loop
task.spawn(function()
while true do
-- WAITING
GameManager.setState("Waiting")
while #Players:GetPlayers() < CONFIG.MIN_PLAYERS do
task.wait(1)
end
-- MAP VOTING
VotingManager.startVote(3)
for i = 10, 1, -1 do
task.wait(1)
end
local selectedMap = VotingManager.getWinner()
-- COUNTDOWN
GameManager.setState("Starting")
for i = CONFIG.COUNTDOWN_TIME, 1, -1 do
if #Players:GetPlayers() < CONFIG.MIN_PLAYERS then
break
end
task.wait(1)
end
if #Players:GetPlayers() < CONFIG.MIN_PLAYERS then
continue
end
-- START ROUND
GameManager.setState("Playing")
setupRound(selectedMap)
local startTime = os.clock()
while os.clock() - startTime < CONFIG.ROUND_TIME do
local winner = checkWinCondition()
if winner then
break
end
task.wait(0.5)
end
-- INTERMISSION
GameManager.setState("Intermission")
local results = calculateResults()
ResultsRemote:FireAllClients(results)
cleanupRound()
task.wait(CONFIG.INTERMISSION_TIME)
end
end)