Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 30

Timed Transitions and Round Management

Recommended resources

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.

Roblox Intermission Timer Example
An example of an intermission timer counting down in a lobby, just like the one we'll build for our `Countdown` state.
Roblox Studio Round System Countdown Thumbnail
This shows a timer for the round itself, indicating how much time is left to play. We can apply today's logic to this as well.

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.

BoolValue not changing?

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.

  1. Locate the StartCountdown event in your StateQ.new() definition table.
  2. 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 a for i = COUNTDOWN_DURATION, 1, -1 do.
  • Inside the loop, we wait one second and then call broadcastStateChange, sending the Countdown state name along with a new data field, timeRemaining.
  • After the loop completes, we trigger the StartRound event. The actual state transition now occurs within the StartRound's on function.

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.

  1. Open your GameStateController LocalScript located in StarterGui > GameStatusUI.
  2. Update the onGameStateChanged function 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/elseif logic to check the newState.
  • For the Countdown and InRound states, it specifically looks for the timeRemaining field in the data table to display the live timer.
  • It also handles the initial duration field 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 for loop with task.wait(1) is a standard and effective pattern for creating countdowns in Roblox.
  • By firing a RemoteEvent inside 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 timeRemaining update).
  • 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.