Hello! Welcome back.
In our last lesson, we successfully synchronized the game state between the server and all clients. The server now acts as the single source of truth, and clients are notified via a RemoteEvent whenever the game transitions between states like Lobby, Countdown, and InRound. This is a fantastic foundation.
However, our implementation of the Countdown state had a small limitation. We used task.delay() to wait for the countdown duration before transitioning. While functional, this approach is a "black box" — the server starts a timer, and clients only know that the countdown has started, not how much time is left.
Today, we will address that. Our learning outcome is to implement timed transitions, such as a countdown timer triggering the start of a round. We will upgrade our system to create a visible, second-by-second countdown on the player's UI, making the game feel much more interactive and professional.
Visualizing the Goal
Before we dive into the code, let's look at what we're aiming for. In many Roblox games, you'll see a timer at the top of the screen counting down the seconds until a round begins or ends.


To achieve this, we can't just use a single task.delay(). We need a mechanism that runs during the timed state, updating clients every second.
The Server-Side Countdown Loop
The most straightforward way to implement a countdown in Luau, and a very common pattern in Roblox development, is to use a for loop that counts backwards, with a task.wait(1) inside it.
This pattern gives us a hook every second to perform an action — in our case, broadcasting the remaining time to all clients.
Let's look at how other developers implement this. The following forum post discusses building a round system, and the provided code examples use the exact countdown loop pattern we need.
This Roblox Developer Forum thread, titled 'BoolValue not changing?', contains excellent examples of a server-side round system. The helper who answers the question provides several code snippets showing an intermission countdown.
Please review the two highlighted code blocks in the thread. The first one, in a post by 'dthecoolest', shows a Game.Intermission function with a for i = 30, 1, -1 loop. The second one, by 'Darky' near the bottom, shows a similar mod:Intermission function. Notice how both use a for loop with task.wait(1) to create a countdown and update a Status value inside the loop. This is the core logic we will be implementing.
1. Updating the Server-Side GameStateManager
Inspired by the examples, let's refactor our GameStateManager script. We'll replace the task.delay() in our StartCountdown event with a for loop.
- Locate the
StartCountdownevent in yourStateQ.new()definition table. - Replace the
task.delay()block with the new countdown loop.
Your StartCountdown event definition should be updated to look like this:
-- In your GameStateManager script...
-- This is the event definition for starting the countdown
[GameEvent.StartCountdown] = {
from = GameState.Lobby,
on = function()
print("FSM: Entered Countdown state.")
-- Announce the start of the countdown state once
broadcastStateChange(GameState.Countdown, { duration = COUNTDOWN_DURATION })
-- Start the countdown loop
for i = COUNTDOWN_DURATION, 1, -1 do
task.wait(1)
-- Broadcast the remaining time every second
-- We are re-using the same RemoteEvent, but the client will handle the data differently.
broadcastStateChange(GameState.Countdown, { timeRemaining = i })
end
-- After the loop finishes, handle the transition to the next state
if GameFSM:getState() == GameState.Countdown then
GameFSM:handle(GameEvent.StartRound)
end
-- Note: The return value is now handled by the StartRound event's 'on' callback.
-- This function doesn't need to return a state.
end,
},
-- Ensure your StartRound event sets and returns the correct state
[GameEvent.StartRound] = {
from = GameState.Countdown,
on = function()
print("FSM: Entered InRound state.")
broadcastStateChange(GameState.InRound, { duration = ROUND_DURATION })
-- We will add a countdown for the round later
return GameState.InRound -- Make sure this returns the new state
end,
},
-- ... other states
Key Changes:
- We replaced
task.delay()with afor i = COUNTDOWN_DURATION, 1, -1 do. - Inside the loop, we wait one second and then call
broadcastStateChange, sending theCountdownstate name along with a new data field,timeRemaining. - After the loop completes, we trigger the
StartRoundevent. The actual state transition now occurs within theStartRound'sonfunction.
2. Updating the Client to Display the Timer
Now that the server is sending updates every second, we need to update our client-side GameStateController script to display them correctly.
This short video explains the exact client-server communication pattern for a timer UI.
Roblox Tutorial - Round system + Timer UI
In this tutorial by Eppobot, the creator explains how to build a round system timer. The key part for us is how the server sends the time and the client receives it to update a UI label.
Watch from 01:15 to 02:59. The video first shows the server-side for loop (01:15 - 02:22) that sends the time via a RemoteEvent. Then, it explains the client-side script (02:22 - 02:59) that listens for this event and updates the UI text. This perfectly matches the logic we are implementing.
Let's modify our GameStateController LocalScript to handle these new, frequent updates.
- Open your
GameStateControllerLocalScriptlocated inStarterGui > GameStatusUI. - Update the
onGameStateChangedfunction to intelligently handle the data payload.
Your onGameStateChanged function should now look like this:
-- In your GameStateController LocalScript...
local function onGameStateChanged(newState, data)
print("Client received state update:", newState, data)
if newState == GameState.Lobby then
StatusLabel.Text = "Waiting for players..."
elseif newState == GameState.Countdown then
if data and data.timeRemaining then
StatusLabel.Text = "Intermission: " .. data.timeRemaining
elseif data and data.duration then
-- This handles the initial announcement
StatusLabel.Text = "Intermission: " .. data.duration
end
elseif newState == GameState.InRound then
if data and data.timeRemaining then
StatusLabel.Text = "Round ends in: " .. data.timeRemaining
elseif data and data.duration then
StatusLabel.Text = "Round in progress! (" .. data.duration .. "s)"
end
elseif newState == GameState.PostRound then
StatusLabel.Text = "Round over!"
else
StatusLabel.Text = "Status: " .. newState
end
end
-- The line connecting the event remains the same
GameStateChanged.OnClientEvent:Connect(onGameStateChanged)
Key Changes:
- The function now has
if/elseiflogic to check thenewState. - For the
CountdownandInRoundstates, it specifically looks for thetimeRemainingfield in thedatatable to display the live timer. - It also handles the initial
durationfield we send, ensuring the timer displays correctly from the very start.
3. Expanding the Pattern
You can now apply this exact same pattern to your other timed states. For example, to add a countdown for the round itself, you would add a for loop to the on callback of your StartRound event in the GameStateManager.
Here's how you might update the StartRound event:
-- In your GameStateManager...
[GameEvent.StartRound] = {
from = GameState.Countdown,
on = function()
print("FSM: Entered InRound state.")
broadcastStateChange(GameState.InRound, { duration = ROUND_DURATION })
-- Countdown loop for the round itself
for i = ROUND_DURATION, 1, -1 do
task.wait(1)
broadcastStateChange(GameState.InRound, { timeRemaining = i })
end
-- After the round timer ends, transition to PostRound
if GameFSM:getState() == GameState.InRound then
GameFSM:handle(GameEvent.EndRound)
end
return GameState.InRound
end,
},
With this change and the client-side code we already wrote, your UI will now seamlessly show the round timer as well!
Conclusion
Great job! You have now implemented a robust and visible timed transition system. Instead of relying on a simple delay, our FSM now actively manages a countdown on the server and keeps all clients perfectly synchronized with a second-by-second UI timer.
Key Takeaways:
- A server-side
forloop withtask.wait(1)is a standard and effective pattern for creating countdowns in Roblox. - By firing a
RemoteEventinside the loop, we can provide clients with live updates for timers. - The client-side listener needs to be designed to handle different types of data payloads for the same event (e.g., an initial duration vs. a
timeRemainingupdate). - This pattern can be reused for any timed state in your game, such as intermissions, round timers, or power-up durations.
Next Lesson Preview:
Our game state manager is now fully functional and provides great visual feedback to the players. In the next lesson, we will make the state matter even more by connecting it to gameplay logic. We'll implement code that uses the current game state to manage player controls, such as disabling their ability to move while in the lobby.