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.
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.
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
phasevariable 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 > 0for a living character.
Vulnerable example
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
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.