How to check the round state before allowing an action

Bets after the winner is known and answers after the reveal. How to keep round and player state on the server and check it in every handler.

criticalLuaudit check: unchecked-game-state

What it looks like

An action only makes sense at certain times: bets while betting is open, answers while a question is open, abilities while the player is alive, buying from the lobby shop between rounds. The client only shows the button at the right time, and the server handler checks the arguments but not whether the game is in a state where the action is allowed.

Luau
remote.OnServerEvent:Connect(function(player, horse, amount)
	placeBet(player, horse, amount)
end)

How to detect it

  • Find state the server keeps that limits what players can do: a round phase such as intermission, betting or playing, an open or closed question, whether a player is alive, in a match, on a team or stunned.
  • For each remote that should only work in some of those states, look for a server check of the current state at the top of the handler, before anything changes.
  • Look at the gaps between states as well: after a round ends but before the next starts, after a reveal, while a player is dead or respawning.
  • Hiding a button, closing a menu or disabling a UI on the client doesn't count. The client can fire the remote at any time.
  • If the handler yields, the state can change during the wait. Check it again after the yield, or see Check and change split by a yield.

What an exploiter could do

They act when the game says they can't: bet after the winner is known, answer after the answer is shown, buy round-only items mid-round, use abilities while dead or keep scoring after the match has ended. Anything that's meant to be decided by timing or risk becomes a free win.

How to fix it

  • Keep the game state on the server, such as a phase variable or a round number, and change it only from server code.
  • At the top of each handler, return early unless the state allows the action.
  • Tie per-round actions to a round id, so an action from one round can't count in the next, and record that the player has acted this round.
  • For actions that depend on the player's own state, check it on the server, such as humanoid.Health > 0 for a living character.

Vulnerable example

Vulnerable
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local PlayerData = require(script.Parent.PlayerData)

local horses = { "Comet", "Biscuit", "Thunder" }
local bets: { [Player]: { horse: string, amount: number } } = {}
local bettingOpen = false

ReplicatedStorage.Remotes.PlaceBet.OnServerEvent:Connect(function(player: Player, horse: unknown, amount: unknown)
	if typeof(horse) ~= "string" or not table.find(horses, horse) then
		return
	end
	if typeof(amount) ~= "number" or amount ~= math.floor(amount) or amount < 1 or amount > 500 then
		return
	end
	local profile = PlayerData.get(player)
	if not profile or bets[player] or profile.Coins < amount then
		return
	end
	profile.Coins -= amount
	bets[player] = { horse = horse, amount = amount }
end)

local Race = {}

function Race.openBetting()
	table.clear(bets)
	bettingOpen = true
end

function Race.closeBetting()
	bettingOpen = false
end

function Race.settle(winner: string)
	bettingOpen = false
	for player, bet in bets do
		local profile = PlayerData.get(player)
		if profile and bet.horse == winner then
			profile.Coins += bet.amount * 3
		end
	end
	table.clear(bets)
end

Players.PlayerRemoving:Connect(function(player)
	bets[player] = nil
end)

return Race

Fixed example

Fixed
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local PlayerData = require(script.Parent.PlayerData)

local horses = { "Comet", "Biscuit", "Thunder" }
local bets: { [Player]: { horse: string, amount: number } } = {}
local bettingOpen = false

ReplicatedStorage.Remotes.PlaceBet.OnServerEvent:Connect(function(player: Player, horse: unknown, amount: unknown)
	if not bettingOpen then
		return
	end
	if typeof(horse) ~= "string" or not table.find(horses, horse) then
		return
	end
	if typeof(amount) ~= "number" or amount ~= math.floor(amount) or amount < 1 or amount > 500 then
		return
	end
	local profile = PlayerData.get(player)
	if not profile or bets[player] or profile.Coins < amount then
		return
	end
	profile.Coins -= amount
	bets[player] = { horse = horse, amount = amount }
end)

local Race = {}

function Race.openBetting()
	table.clear(bets)
	bettingOpen = true
end

function Race.closeBetting()
	bettingOpen = false
end

function Race.settle(winner: string)
	bettingOpen = false
	for player, bet in bets do
		local profile = PlayerData.get(player)
		if profile and bet.horse == winner then
			profile.Coins += bet.amount * 3
		end
	end
	table.clear(bets)
end

Players.PlayerRemoving:Connect(function(player)
	bets[player] = nil
end)

return Race

Common false positives

  • Actions that are allowed at any time, such as changing settings or emoting, don't need a state check.
  • A state check inside a helper module or a shared guard function counts. Read it before reporting.
  • A handler connected only while the action is allowed isn't a finding on its own, but calls fired while it's disconnected are queued and run when it connects again. Check that running them late is harmless.