Skip to main content

Welcome to a Grasp lesson

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

Lesson 28

Game State Transitions

Recommended resources

Hello again! It's great to see you back.

In our last lesson, we successfully created the foundational structure for our game's flow by setting up a GameStateManager script with the StateQ library. We defined our four key states—Lobby, Countdown, InRound, and PostRound—and the events that connect them. However, right now, our machine is static; it won't transition unless we manually trigger an event.

Today, we'll bring that structure to life. Our goal is to implement the core transition logic to cycle through the game states in the correct order. We will add the "brains" to our FSM, making it automatically progress through the game loop based on conditions like player count and timers.

1. Triggering Transitions with handle()

In the StateQ library, all transitions are initiated by calling the handle() method on your FSM instance. This method takes the name of the event you want to trigger.

For example, to start the countdown, we would call:

GameFSM:handle(GameEvent.StartCountdown)

The FSM will then check if the StartCountdown event is allowed from the current state (which we defined as Lobby). If it is, the FSM executes the transition logic and moves to the Countdown state.

Our task today is to write the code that calls handle() at the right moments. To do this, we need to add logic that runs when we enter a state (on callbacks) and logic that runs continuously while we are in a state (whileStateIs loops).


2. Automating the Game Loop

Let's modify our GameStateManager script to include this new automation logic. We will go through the cycle step-by-step.

From Lobby to Countdown

The transition from Lobby to Countdown should happen when enough players have joined the game. Let's set a minimum requirement of 2 players.

We can achieve this by listening to the PlayerAdded and PlayerRemoving events from the Players service. We'll create a function that checks the player count and, if the criteria are met and we are in the Lobby state, triggers the StartCountdown event.

Here is the code to add to your GameStateManager script:

-- Add these variables near the top of your script
local Players = game:GetService("Players")
local MIN_PLAYERS_TO_START = 2

-- Add this function definition below your FSM creation
local function tryStartCountdown()
	-- Only proceed if we are in the Lobby and have enough players
	if GameFSM:getState() == GameState.Lobby and #Players:GetPlayers() >= MIN_PLAYERS_TO_START then
		print("Minimum player count reached. Starting countdown.")
		GameFSM:handle(GameEvent.StartCountdown)
	end
end

-- Connect the function to player events
Players.PlayerAdded:Connect(tryStartCountdown)
Players.PlayerRemoving:Connect(tryStartCountdown)

Now, whenever a player joins or leaves, this function will check if it's time to start the game.

From Countdown and PostRound (Timed Transitions)

The transitions from Countdown to InRound and from PostRound back to Lobby are both based on time. We want to wait for a specific duration and then automatically trigger the next event.

The StateQ library allows us to define on callbacks that fire when a state is entered. This is the perfect place to start a timer. We'll use task.delay() to schedule the transition.

Let's update our FSM definition to include these timed transitions. We'll replace the placeholder beforeAsync functions with on blocks that contain our logic.

Here's how we define the on callback for the Countdown state:

-- Inside your StateQ.new() definition table

[GameEvent.StartCountdown] = {
    from = GameState.Lobby,
    on = function() -- 'on' fires when the state is entered
        print("FSM Event: StartCountdown. Entered Countdown state.")
        
        local countdownDuration = 10 -- seconds
        print("Countdown starting for "..tostring(countdownDuration).." seconds.")
        
        -- After the duration, handle the event to start the round
        task.delay(countdownDuration, function()
            -- Add a check to ensure we are still in the Countdown state
            if GameFSM:getState() == GameState.Countdown then
                GameFSM:handle(GameEvent.StartRound)
            end
        end)
        
        return GameState.Countdown
    end,
},

We can apply the exact same pattern for the PostRound state to transition back to the Lobby.

From InRound to PostRound (Conditional Transitions)

The InRound state is the most complex. It should end under two conditions:

  1. The round timer runs out.
  2. A winner is decided (e.g., only one player is left).

To handle this, we need logic that runs continuously every frame or every second while the game is in the InRound state. For this, StateQ provides the whileStateIs method.

First, to determine a winner, we need a way to track who is actively playing. A common and effective pattern in Roblox is to use the Teams service.

How to Make a Smart and Efficient Round System PART 2 | Roblox Studio Scripting Tutorial

This video from Reactive Metal demonstrates a practical way to manage players in a round-based game using Teams. It's a great illustration of the logic we're about to implement.

First, watch from 0:53 to 2:13 to see how to set up 'Lobby' and 'Playing' teams in Studio. Then, watch from 6:05 to 11:33 to understand the logic for checking the number of players on the 'Playing' team to determine a winner. We will implement a similar concept in our FSM.

Inspired by this, we'll write a loop that runs only during the InRound state. This loop will check the number of active players and a round timer.

Here is the logic for the InRound state. Note how we define an on callback to set things up (like teleporting players) and a separate whileStateIs loop to check for the end-of-round conditions.

-- Inside your StateQ.new() definition table

[GameEvent.StartRound] = {
    from = GameState.Countdown,
    on = function()
        print("FSM Event: StartRound. Entered InRound state.")
        -- In a real game, you would teleport players here.
        return GameState.InRound
    end,
},

-- Add this loop *after* your FSM is created
task.spawn(function()
	local ROUND_DURATION = 120 -- 2 minutes
	local roundTimeRemaining = ROUND_DURATION

	GameFSM:whileStateIs(GameState.InRound, function(dt)
		roundTimeRemaining -= dt -- Decrement timer by deltaTime

		-- For this example, we'll simulate checking for a winner.
		-- In a real game, you'd check the number of players on the "Playing" team.
		local playersInRound = #Players:GetPlayers() -- Simplified for now

		if roundTimeRemaining <= 0 then
			print("Round timer ended.")
			GameFSM:handle(GameEvent.EndRound, { reason = "Time out!" }) -- Optional data payload
		elseif playersInRound <= 1 then
			print("A winner has been decided.")
			GameFSM:handle(GameEvent.EndRound, { reason = "Last player standing!" })
		end
		
		task.wait(1) -- Check conditions once per second
	end,
	function()
		-- This 'onExit' function resets the timer for the next round
		roundTimeRemaining = ROUND_DURATION
		print("Exiting InRound state. Resetting round timer.")
	end)
end)

3. The Complete GameStateManager Script

Here is the fully updated GameStateManager script incorporating all the automated transition logic. You can replace the entire content of your script with this.

-- GameStateManager script, located in ServerScriptService

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")

-- 1. Require the StateQ library
local StateQ = require(ReplicatedStorage.Packages.StateQ)

-- 2. Define constants
local MIN_PLAYERS_TO_START = 2
local COUNTDOWN_DURATION = 10
local ROUND_DURATION = 120
local POST_ROUND_DURATION = 15

-- 3. Define the states for our game loop
local GameState = {
	Lobby = "Lobby",
	Countdown = "Countdown",
	InRound = "InRound",
	PostRound = "PostRound",
}

-- 4. Define the events that will trigger transitions
local GameEvent = {
	StartCountdown = "StartCountdown",
	StartRound = "StartRound",
	EndRound = "EndRound",
	ResetLobby = "ResetLobby",
}

-- 5. Create the Game State FSM instance
print("Creating Game State FSM...")
local GameFSM = StateQ.new(
	GameState.Lobby, -- The initial state
	{
		[GameEvent.StartCountdown] = {
			from = GameState.Lobby,
			on = function()
				print("FSM: Entered Countdown state.")
				task.delay(COUNTDOWN_DURATION, function()
					if GameFSM:getState() == GameState.Countdown then
						GameFSM:handle(GameEvent.StartRound)
					end
				end)
				return GameState.Countdown
			end,
		},

		[GameEvent.StartRound] = {
			from = GameState.Countdown,
			on = function()
				print("FSM: Entered InRound state. The battle begins!")
				-- TODO: Teleport players, set up the round.
				return GameState.InRound
			end,
		},

		[GameEvent.EndRound] = {
			from = GameState.InRound,
			on = function(data) -- 'data' can be passed from the handle() call
				print("FSM: Entered PostRound state. Reason: " .. (data and data.reason or "Unknown"))
				-- TODO: Display winner, show scores.
				task.delay(POST_ROUND_DURATION, function()
					if GameFSM:getState() == GameState.PostRound then
						GameFSM:handle(GameEvent.ResetLobby)
					end
				end)
				return GameState.PostRound
			end,
		},

		[GameEvent.ResetLobby] = {
			from = GameState.PostRound,
			on = function()
				print("FSM: Entered Lobby state. Waiting for players...")
				-- TODO: Teleport players back to lobby.
				-- After returning to the lobby, immediately check if we can start again.
				task.wait(1) -- Small delay to allow game state to settle
				tryStartCountdown()
				return GameState.Lobby
			end,
		},
	}
)

print("Game State FSM created. Initial state:", GameFSM:getState())

-- 6. Define the logic for the InRound state's update loop
task.spawn(function()
	local roundTimeRemaining = ROUND_DURATION
	GameFSM:whileStateIs(GameState.InRound, function(dt)
		roundTimeRemaining -= dt
		
		-- Simplified check for winner
		local playersInRound = #Players:GetPlayers()
		
		if roundTimeRemaining <= 0 then
			GameFSM:handle(GameEvent.EndRound, { reason = "Time out!" })
		elseif playersInRound <= 1 and ROUND_DURATION - roundTimeRemaining > 5 then -- Don't end immediately
			GameFSM:handle(GameEvent.EndRound, { reason = "Last player standing!" })
		end
		
		task.wait(1)
	end,
	function() -- onExit callback
		roundTimeRemaining = ROUND_DURATION -- Reset timer for the next round
	end)
end)


-- 7. Define the function to check player count and start the game
function tryStartCountdown()
	if GameFSM:getState() == GameState.Lobby and #Players:GetPlayers() >= MIN_PLAYERS_TO_START then
		GameFSM:handle(GameEvent.StartCountdown)
	end
end

-- 8. Connect to Player events
Players.PlayerAdded:Connect(tryStartCountdown)
Players.PlayerRemoving:Connect(tryStartCountdown)

-- 9. Initial check in case server starts with enough players
tryStartCountdown()

If you run this code in a test server with two or more players, you will see the FSM automatically cycle through the states, printing its progress to the output window.


Conclusion

Fantastic work today! You have successfully implemented the core logic that drives your game forward. Our GameStateManager is no longer a static structure; it's a dynamic, automated system that manages the entire game loop.

Key Takeaways:

  • Transitions in StateQ are triggered using the FSM:handle(EventName) method.
  • State-entry logic, like starting timers, is placed in on callbacks within the FSM definition.
  • Continuous logic that runs during a state, like checking for win conditions, is best handled with FSM:whileStateIs().
  • External game events, like players joining, can be used to trigger FSM transitions by connecting them to functions that call handle().

Next Lesson Preview:
Our server now has a fully functional game loop. However, the players' games (the clients) have no idea what's happening. In our next lesson, we will focus on synchronizing these game state changes from the server to all clients using RemoteEvents, allowing us to build a responsive user interface that reflects the current state of the game.