Is It Safe to Use RunService:IsServer() in ModuleScript Stored in ReplicatedStorage?

Hi there,

In my current project, I’m working on a match system, and most of the code for this system is located inside ReplicatedStorage. Within the modules, I’m using RunService:IsServer() to separate client and server logic.

However, I’m not entirely sure if this setup is secure or if it could potentially be exploited in a way that would affect other players’ gameplay. I’d really appreciate any guidance on whether this approach is safe or if I should consider restructuring things differently.

local RunService = game:GetService("RunService")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Workspace = game:GetService("Workspace")
local SoundService = game:GetService("SoundService")
local TweenService = game:GetService("TweenService")

local isServer = RunService:IsServer()

local SyncVoteTally = ReplicatedStorage.Remotes.Game.SyncVoteTally
local CastThemeVote = ReplicatedStorage.Remotes.Game.CastThemeVote
local IntermissionTimerSync = ReplicatedStorage.Remotes.Game.IntermissionTimerSync
local ThemeSelected = ReplicatedStorage.Remotes.Game.ThemeSelected

local StateManager = require(script.StateManager)
local VotingSystem = require(script.VotingSystem)
local TimerManager = require(script.TimerManager)
local UIManager = require(script.UIManager)
local Constants = require(script.Constants)

local GameSystem = {
    Server = {},
    Client = {},
    Constants = Constants
}

function GameSystem.Client:FadeSound(soundName, targetVolume, duration, callback)
    if isServer then return end

    local sound = SoundService:FindFirstChild(soundName)
    if not sound then
        warn("Sound not found:", soundName)
        if callback then callback() end
        return
    end

    local audioPrefs = ReplicatedStorage:FindFirstChild("Modules") and 
        ReplicatedStorage.Modules:FindFirstChild("Preferences") and 
        ReplicatedStorage.Modules.Preferences:FindFirstChild("Audio")

    local isEnabled = true
    if audioPrefs then
        local audioModule = require(audioPrefs)
        if soundName == "LobbyMusic" or soundName == "TeamSelector" then
            isEnabled = audioModule:GetValue("LobbyMusic")
        elseif soundName == "FightMusic" then
            isEnabled = audioModule:GetValue("FightMusic")
        end
    end

    if targetVolume <= 0 then
        if not sound.Playing then
            if callback then callback() end
            return
        end

        local tweenInfo = TweenInfo.new(
            duration,
            Enum.EasingStyle.Linear,
            Enum.EasingDirection.Out
        )

        local volumeGoal = {Volume = 0}
        local tween = TweenService:Create(sound, tweenInfo, volumeGoal)
        tween:Play()

        tween.Completed:Connect(function()
            sound.Playing = false
            if callback then callback() end
        end)

        return tween
    else
        if not sound.Playing then
            sound.Volume = 0
            sound.Playing = true
        end

        if not isEnabled then
            if callback then callback() end
            return
        end

        local tweenInfo = TweenInfo.new(
            duration,
            Enum.EasingStyle.Linear,
            Enum.EasingDirection.Out
        )

        local volumeGoal = {Volume = targetVolume}
        local tween = TweenService:Create(sound, tweenInfo, volumeGoal)
        tween:Play()

        tween.Completed:Connect(function()
            if callback then callback() end
        end)

        return tween
    end
end

function GameSystem.Client:UpdateSounds(newState, previousState)
    if isServer then return end

    if newState == "Waiting" or newState == "Intermission" then
        self:FadeSound("TeamSelector", 0, 1, function()
            self:FadeSound("FightMusic", 0, 1)
        end)

        self:FadeSound("LobbyMusic", 1.5, 2)
    elseif newState == "InGame" then
        if previousState == "Intermission" then
            self:FadeSound("LobbyMusic", 0, 2, function()
                if StateManager.Client:GetCurrentState() == "InGame" then
                    self:FadeSound("TeamSelector", 1.5, 2)
                end
            end)

            if not self.teamSelectionCompletionListener then
                self.teamSelectionCompletionListener = ReplicatedStorage.Remotes.Game.TeamSelectionComplete.OnClientEvent:Connect(function()
                    self:FadeSound("TeamSelector", 0, 2, function()
                        if StateManager.Client:GetCurrentState() == "InGame" then
                            self:FadeSound("FightMusic", 1.5, 2)
                        end
                    end)
                end)
            end
        end
    elseif newState == "Ending" then
        self:FadeSound("LobbyMusic", 0, 2)
        self:FadeSound("TeamSelector", 0, 2)
        self:FadeSound("FightMusic", 0, 2)
    end
end

function GameSystem.Server:Initialize()
    if not isServer then return end

    StateManager.Server:Initialize()
    VotingSystem.Server:Initialize()

    if not ReplicatedStorage.Remotes.Game:FindFirstChild("TeamSelectionComplete") then
        local teamSelectionComplete = Instance.new("RemoteEvent")
        teamSelectionComplete.Name = "TeamSelectionComplete"
        teamSelectionComplete.Parent = ReplicatedStorage.Remotes.Game
    end

    Players.PlayerAdded:Connect(function(player)
        self:SyncNewPlayer(player)

        local shouldStartIntermission = StateManager.Server:CheckPlayers()
        if shouldStartIntermission then
            self:StartIntermission()
        end
    end)

    Players.PlayerRemoving:Connect(function(player)
        VotingSystem.Server:RemovePlayerVote(player)

        task.defer(function()
            local stateChanged = StateManager.Server:CheckPlayers()
            if stateChanged == false then
                TimerManager.Server:StopAllTimers()
            end
        end)
    end)

    StateManager.Server:ChangeState("Waiting")
    StateManager.Server:CheckPlayers()

    self:StartPeriodicSync()
end

function GameSystem.Server:StartTeamSelection()
    if not isServer then return end

    self.teams = {
        Red = {},
        Blue = {}
    }

    self.usedSpawns = {
        Red = {},
        Blue = {}
    }

    self.playerSelectedTeam = {}

    if not self.teamSelectionHandler then
        self.teamSelectionHandler = ReplicatedStorage.Remotes.Game.TeamSelection.OnServerEvent:Connect(function(player, team)
            if self.playerSelectedTeam[player.UserId] then
                return
            end

            if team == "Red" or team == "Blue" then
                for teamName, players in pairs(self.teams) do
                    for i, plr in ipairs(players) do
                        if plr == player then
                            table.remove(self.teams[teamName], i)
                            break
                        end
                    end
                end
                table.insert(self.teams[team], player)
                player:SetAttribute("Team", team)

                self.playerSelectedTeam[player.UserId] = team

                ReplicatedStorage.Remotes.Game.TeamSelection:FireClient(player, team, true)
            end
        end)
    end

    TimerManager.Server:StartTeamSelectionTimer(function()
        self:FinalizeTeamSelection()
    end)
end

function GameSystem.Server:TeleportPlayersToSpawns()
    if not isServer then return end
    local arenaSpawns = Workspace:FindFirstChild("Arena") and Workspace.Arena:FindFirstChild("Spawns")
    if not arenaSpawns then
        warn("Arena spawns not found in Workspace.Arena.Spawns")
        return
    end

    for team, players in pairs(self.teams) do
        local teamSpawns = arenaSpawns:FindFirstChild(team)
        if not teamSpawns then
            warn("Team spawns not found for team: " .. team)
            continue
        end

        local availableSpawns = {}
        for _, spawn in pairs(teamSpawns:GetChildren()) do
            if spawn:IsA("BasePart") and not self.usedSpawns[team][spawn.Name] then
                table.insert(availableSpawns, spawn)
            end
        end

        for _, player in ipairs(players) do
            if #availableSpawns == 0 then
                warn("No available spawns for team: " .. team)
                break
            end

            local randomIndex = math.random(1, #availableSpawns)
            local selectedSpawn = availableSpawns[randomIndex]
            local character = player.Character or player.CharacterAdded:Wait()

            if character and character:FindFirstChild("HumanoidRootPart") then
                character:SetPrimaryPartCFrame(CFrame.new(selectedSpawn.Position + Vector3.new(0, 3, 0)))
                self.usedSpawns[team][selectedSpawn.Name] = true
                table.remove(availableSpawns, randomIndex)
                self:AddTeamHighlight(character, team)
            end
        end
    end

    UIManager.Server:UpdateScoreboardData(self.teams)
end

function GameSystem.Server:AddTeamHighlight(character, team)
    if not isServer then return end
    local highlight = Instance.new("Highlight")
    highlight.Name = "TeamHighlight"
    highlight.FillTransparency = 1

    if team == "Red" then
        highlight.OutlineColor = Color3.fromRGB(255, 0, 0)
    elseif team == "Blue" then
        highlight.OutlineColor = Color3.fromRGB(67, 67, 255)
    end

    highlight.Parent = character
end

function GameSystem.Server:FinalizeTeamSelection()
    if not isServer then return end

    if self.teamSelectionHandler then
        self.teamSelectionHandler:Disconnect()
        self.teamSelectionHandler = nil
    end

    self:TeleportPlayersToSpawns()

    ReplicatedStorage.Remotes.Game.TeamSelectionComplete:FireAllClients()

    self:StartGameplay()
end

function GameSystem.Client:StartTeamTransition()
    if isServer then return end

    local player = Players.LocalPlayer
    if not player then return end

    local playerGui = player:WaitForChild("PlayerGui")
    local gameGui = playerGui:WaitForChild("Game")
    local inGameFrame = gameGui:WaitForChild("InGame")
    local transitionFrame = inGameFrame:WaitForChild("Transition")
    local scoreboardFrame = inGameFrame:WaitForChild("Scoreboard")

    scoreboardFrame.Visible = false
    transitionFrame.Visible = true
    transitionFrame.Position = UDim2.new(-1.998, 0, 0.196, 0)

    self:FadeSound("TeamSelector", 0, 2)
    self:FadeSound("FightMusic", 1.5, 2)

    self.teamSelectionComplete = true

    local tweenInfo = TweenInfo.new(
        1.5,
        Enum.EasingStyle.Cubic,
        Enum.EasingDirection.Out
    )

    local positionGoal = {Position = UDim2.new(4, 0, 0.605, 0)}
    local tween = TweenService:Create(transitionFrame, tweenInfo, positionGoal)
    tween:Play()

    tween.Completed:Connect(function()
        self:StartTeamCameraSequence()
    end)
end

function GameSystem.Client:StartTeamCameraSequence()
    if isServer then return end

    local player = Players.LocalPlayer
    if not player then return end

    local character = player.Character
    if not character then return end

    local humanoid = character:FindFirstChild("Humanoid")
    if not humanoid then return end

    local originalCameraType = workspace.CurrentCamera.CameraType

    workspace.CurrentCamera.CameraType = Enum.CameraType.Scriptable

    local arena = workspace:FindFirstChild("Arena")
    if not arena then
        warn("Arena not found, cannot perform camera sequence")
        workspace.CurrentCamera.CameraType = originalCameraType
        self:FinishTeamTransition()
        return
    end

    local spawns = arena:FindFirstChild("Spawns")
    if not spawns then
        warn("Spawns not found in Arena, cannot perform camera sequence")
        workspace.CurrentCamera.CameraType = originalCameraType
        self:FinishTeamTransition()
        return
    end

    local redSpawns = spawns:FindFirstChild("Red")
    if not redSpawns then
        warn("Red team spawns not found")
        workspace.CurrentCamera.CameraType = originalCameraType
        self:FinishTeamTransition()
        return
    end

    local redStartCamera = redSpawns:FindFirstChild("StartCamera")
    local redEndCamera = redSpawns:FindFirstChild("EndCamera")

    if not redStartCamera or not redEndCamera then
        warn("Red team camera points not found")
        workspace.CurrentCamera.CameraType = originalCameraType
        self:FinishTeamTransition()
        return
    end

    local blueSpawns = spawns:FindFirstChild("Blue")
    if not blueSpawns then
        warn("Blue team spawns not found")
        workspace.CurrentCamera.CameraType = originalCameraType
        self:FinishTeamTransition()
        return
    end

    local blueStartCamera = blueSpawns:FindFirstChild("StartCamera")
    local blueEndCamera = blueSpawns:FindFirstChild("EndCamera")

    if not blueStartCamera or not blueEndCamera then
        warn("Blue team camera points not found")
        workspace.CurrentCamera.CameraType = originalCameraType
        self:FinishTeamTransition()
        return
    end

    local redLookTarget = self:CalculateTeamLookDirection("Red")
    local blueLookTarget = self:CalculateTeamLookDirection("Blue")

    local camera = workspace.CurrentCamera
    camera.CFrame = CFrame.new(redStartCamera.Position, redLookTarget)

    self:AnimateCameraFromTo(redStartCamera.CFrame, redEndCamera.CFrame, redLookTarget, 3, function()
        task.delay(1, function()
            camera.CFrame = CFrame.new(blueStartCamera.Position, blueLookTarget)
            self:AnimateCameraFromTo(blueStartCamera.CFrame, blueEndCamera.CFrame, blueLookTarget, 3, function()
                task.delay(1, function()
                    workspace.CurrentCamera.CameraType = originalCameraType
                    self:FinishTeamTransition()
                end)
            end)
        end)
    end)
end

function GameSystem.Client:AnimateCameraToPoint(targetCFrame, lookTarget, duration, callback)
    if isServer then
        if callback then callback() end
        return
    end

    local camera = workspace.CurrentCamera
    local startCFrame = camera.CFrame
    local startTime = tick()

    local targetPosition = targetCFrame.Position
    local targetLookCFrame = CFrame.new(targetPosition, lookTarget)

    if self.cameraUpdateConnection then
        self.cameraUpdateConnection:Disconnect()
        self.cameraUpdateConnection = nil
    end

    self.cameraUpdateConnection = RunService.RenderStepped:Connect(function()
        local elapsed = tick() - startTime
        local alpha = math.min(elapsed / duration, 1)

        alpha = -(math.cos(math.pi * alpha) - 1) / 2

        camera.CFrame = startCFrame:Lerp(targetLookCFrame, alpha)

        if alpha >= 1 then
            if self.cameraUpdateConnection then
                self.cameraUpdateConnection:Disconnect()
                self.cameraUpdateConnection = nil
            end

            if callback then
                callback()
            end
        end
    end)
end

function GameSystem.Client:AnimateCameraFromTo(startCFrame, endCFrame, lookTarget, duration, callback)
    if isServer then
        if callback then callback() end
        return
    end

    local camera = workspace.CurrentCamera
    local startTime = tick()

    if self.cameraUpdateConnection then
        self.cameraUpdateConnection:Disconnect()
        self.cameraUpdateConnection = nil
    end

    self.cameraUpdateConnection = RunService.RenderStepped:Connect(function()
        local elapsed = tick() - startTime
        local alpha = math.min(elapsed / duration, 1)

        alpha = -(math.cos(math.pi * alpha) - 1) / 2

        local currentPosition = startCFrame.Position:Lerp(endCFrame.Position, alpha)

        camera.CFrame = CFrame.new(currentPosition, lookTarget)

        if alpha >= 1 then
            if self.cameraUpdateConnection then
                self.cameraUpdateConnection:Disconnect()
                self.cameraUpdateConnection = nil
            end

            if callback then
                callback()
            end
        end
    end)
end

function GameSystem.Client:FinishTeamTransition()
    if isServer then return end

    local player = Players.LocalPlayer
    if not player then return end

    local playerGui = player:WaitForChild("PlayerGui")
    local gameGui = playerGui:WaitForChild("Game")
    local inGameFrame = gameGui:WaitForChild("InGame")
    local transitionFrame = inGameFrame:WaitForChild("Transition")
    local scoreboardFrame = inGameFrame:WaitForChild("Scoreboard")

    transitionFrame.Visible = false
    scoreboardFrame.Visible = true
end

function GameSystem.Client:CalculateTeamLookDirection(teamName)
    if isServer then return Vector3.new(0, 0, -1) end

    local arena = workspace:FindFirstChild("Arena")
    if not arena then return Vector3.new(0, 0, -1) end

    local spawns = arena:FindFirstChild("Spawns")
    if not spawns then return Vector3.new(0, 0, -1) end

    local teamSpawns = spawns:FindFirstChild(teamName)
    if not teamSpawns then return Vector3.new(0, 0, -1) end

    local centerPosition = Vector3.new(0, 0, 0)
    local count = 0

    for _, player in pairs(Players:GetPlayers()) do
        if player:GetAttribute("Team") == teamName then
            local character = player.Character
            if character and character:FindFirstChild("HumanoidRootPart") then
                centerPosition = centerPosition + character.HumanoidRootPart.Position
                count = count + 1
            end
        end
    end

    if count == 0 then
        for _, spawn in pairs(teamSpawns:GetChildren()) do
            if spawn:IsA("BasePart") and not (spawn.Name == "StartCamera" or spawn.Name == "EndCamera") then
                centerPosition = centerPosition + spawn.Position
                count = count + 1
            end
        end
    end

    if count > 0 then
        centerPosition = centerPosition / count
        centerPosition = centerPosition + Vector3.new(0, 1.5, 0)

        return centerPosition
    end

    return teamSpawns.Position + Vector3.new(0, 1.5, 0)
end

function GameSystem.Server:StartGameplay()
    if not isServer then return end
end

function GameSystem.Server:SyncNewPlayer(player)
    if not isServer then return end

    local currentState = StateManager.Server:GetCurrentState()
    StateManager.Server:ChangeState(currentState, player)

    local votesData = VotingSystem.Server:GetVotesData()
    if votesData then
        SyncVoteTally:FireClient(player, votesData.themeVotes)

        if votesData.playerVotes[player.UserId] then
            CastThemeVote:FireClient(player, votesData.playerVotes[player.UserId])
        end
    end

    if currentState == "Intermission" then
        local timeLeft = TimerManager.Server:GetIntermissionTimeLeft()
        if timeLeft then
            IntermissionTimerSync:FireClient(player, math.max(0, math.floor(timeLeft)))
        end
    end

    local selectedTheme = StateManager.Server:GetSelectedTheme()
    if selectedTheme then
        ThemeSelected:FireClient(player, selectedTheme)
    end

    if self.teams then
        for teamName, players in pairs(self.teams) do
            for _, plr in ipairs(players) do
                if plr == player then
                    player:SetAttribute("Team", teamName)
                    break
                end
            end
        end
    end
end

function GameSystem.Server:StartPeriodicSync()
    if not isServer then return end

    if self.syncLoop then
        self.syncLoop:Disconnect()
    end

    self.syncTimer = 0
    self.syncLoop = RunService.Heartbeat:Connect(function(dt)
        self.syncTimer = self.syncTimer + dt

        if self.syncTimer >= 5 then
            self.syncTimer = 0

            for _, player in pairs(Players:GetPlayers()) do
                self:SyncNewPlayer(player)
            end
        end
    end)
end

function GameSystem.Server:StartIntermission()
    if not isServer then return end

    VotingSystem.Server:ResetVotes()

    TimerManager.Server:StartIntermissionTimer(function()
        if #Players:GetPlayers() >= Constants.MIN_PLAYERS then
            local winningTheme = VotingSystem.Server:GetWinningTheme()
            StateManager.Server:SetSelectedTheme(winningTheme)
            self:StartGame(winningTheme)
        else
            StateManager.Server:ChangeState("Waiting")
        end
    end)
end

function GameSystem.Server:StartGame(selectedTheme)
    if not isServer then return end

    StateManager.Server:ChangeState("InGame")
    self:StartTeamSelection()

    TimerManager.Server:StartGameTimer(function()
        self:EndGame()
    end)
end

function GameSystem.Server:EndGame()
    if not isServer then return end

    StateManager.Server:ChangeState("Ending")

    TimerManager.Server:StartEndingTimer(function()
        if #Players:GetPlayers() >= Constants.MIN_PLAYERS then
            StateManager.Server:ChangeState("Intermission")
            self:StartIntermission()
        else
            StateManager.Server:ChangeState("Waiting")
        end
    end)
end

function GameSystem.Client:Initialize()
    if isServer then return end

    StateManager.Client:Initialize()
    VotingSystem.Client:Initialize()
    TimerManager.Client:Initialize()
    UIManager.Client:Initialize()

    local audioPrefs = ReplicatedStorage:FindFirstChild("Modules") and 
        ReplicatedStorage.Modules:FindFirstChild("Preferences") and 
        ReplicatedStorage.Modules.Preferences:FindFirstChild("Audio")

    if audioPrefs then
        local audioModule = require(audioPrefs)

        ReplicatedStorage.Remotes.Preferences.Update.Event:Connect(function(category, key, value)
            if category == "Audio" then
                local currentState = StateManager.Client:GetCurrentState()

                if key == "LobbyMusic" then
                    local lobbyMusic = SoundService:FindFirstChild("LobbyMusic")
                    local teamSelector = SoundService:FindFirstChild("TeamSelector")

                    if currentState == "Waiting" or currentState == "Intermission" then
                        if lobbyMusic and lobbyMusic.Playing then
                            lobbyMusic.Volume = value and 1.5 or 0
                        end
                    elseif currentState == "InGame" then
                        if teamSelector and teamSelector.Playing then
                            teamSelector.Volume = value and 1.5 or 0
                        end
                    end
                elseif key == "FightMusic" then
                    local fightMusic = SoundService:FindFirstChild("FightMusic")

                    if currentState == "InGame" and fightMusic and fightMusic.Playing then
                        fightMusic.Volume = value and 1.5 or 0
                    end
                end
            end
        end)
    end

    StateManager.Client.onStateChanged.Event:Connect(function(newState, previousState)
        UIManager.Client:UpdateUI(newState, previousState)
        self:UpdateSounds(newState, previousState)
    end)

    local lastVotesData = {}

    VotingSystem.Client.onVotesUpdated.Event:Connect(function(votesData)
        lastVotesData = votesData
        UIManager.Client:UpdateVotesDisplay(votesData, VotingSystem.Client:GetCurrentVote())
    end)

    VotingSystem.Client.onVoteConfirmed.Event:Connect(function(confirmedTheme)
        UIManager.Client:UpdateVotesDisplay(lastVotesData, confirmedTheme)
    end)

    TimerManager.Client.onIntermissionTimeUpdated.Event:Connect(function(timeLeft)
        UIManager.Client:UpdateIntermissionTime(timeLeft)
    end)

    UIManager.Client:SetupThemeButtons(function(theme)
        VotingSystem.Client:VoteForTheme(theme)
    end)

    UIManager.Client:UpdateUI(StateManager.Client:GetCurrentState())

    if not self.teamSelectionCompletionListener then
        self.teamSelectionCompletionListener = ReplicatedStorage.Remotes.Game.TeamSelectionComplete.OnClientEvent:Connect(function()
            self:StartTeamTransition()
        end)
    end

    local currentState = StateManager.Client:GetCurrentState()
    self:UpdateSounds(currentState, nil)

    self.scoreboardUpdateLoop = RunService.RenderStepped:Connect(function()
        local playerTeam = Players.LocalPlayer:GetAttribute("Team")
        if playerTeam then
            UIManager.Client:UpdateTeamScoreboard(playerTeam)

            local oppositeTeam = playerTeam == "Red" and "Blue"
            UIManager.Client:UpdateTeamScoreboard(oppositeTeam)
        end
    end)

    self:SetupAudioPreferenceListeners()
end

function GameSystem.Client:SetupAudioPreferenceListeners()
    if isServer then return end

    local audioPrefs = ReplicatedStorage:FindFirstChild("Modules") and 
        ReplicatedStorage.Modules:FindFirstChild("Preferences") and 
        ReplicatedStorage.Modules.Preferences:FindFirstChild("Audio")

    if not audioPrefs then return end

    local audioModule = require(audioPrefs)

    local lobbyEnabled = audioModule:GetValue("LobbyMusic")
    local fightEnabled = audioModule:GetValue("FightMusic")

    if not self.audioPreferenceConnection then
        self.audioPreferenceConnection = ReplicatedStorage.Remotes.Preferences.Update.Event:Connect(function(category, key, value)
            if category ~= "Audio" then return end

            local currentState = StateManager.Client:GetCurrentState()

            if key == "LobbyMusic" then
                local lobbyMusic = SoundService:FindFirstChild("LobbyMusic")
                local teamSelector = SoundService:FindFirstChild("TeamSelector")

                if lobbyMusic and lobbyMusic.Playing then
                    if currentState == "Waiting" or currentState == "Intermission" then
                        lobbyMusic.Volume = value and 1.5 or 0
                    end
                end

                if teamSelector and teamSelector.Playing then
                    if currentState == "InGame" and not self.teamSelectionComplete then
                        teamSelector.Volume = value and 1.5 or 0
                    end
                end
            elseif key == "FightMusic" then
                local fightMusic = SoundService:FindFirstChild("FightMusic")

                if fightMusic and fightMusic.Playing then
                    if currentState == "InGame" and self.teamSelectionComplete then
                        fightMusic.Volume = value and 1.5 or 0
                    end
                end
            end
        end)
    end
end

function GameSystem.Server:Cleanup()
    if not isServer then return end

    TimerManager.Server:StopAllTimers()

    if self.syncLoop then
        self.syncLoop:Disconnect()
        self.syncLoop = nil
    end

    if self.teamSelectionHandler then
        self.teamSelectionHandler:Disconnect()
        self.teamSelectionHandler = nil
    end

    self.teams = nil
    self.usedSpawns = nil
end

function GameSystem.Client:Cleanup()
    if isServer then return end

    StateManager.Client:Cleanup()
    VotingSystem.Client:Cleanup()
    TimerManager.Client:Cleanup()
    UIManager.Client:Cleanup()

    if self.teamSelectionCompletionListener then
        self.teamSelectionCompletionListener:Disconnect()
        self.teamSelectionCompletionListener = nil
    end

    if self.audioPreferenceConnection then
        self.audioPreferenceConnection:Disconnect()
        self.audioPreferenceConnection = nil
    end

    if self.scoreboardUpdateLoop then
        self.scoreboardUpdateLoop:Disconnect()
        self.scoreboardUpdateLoop = nil
    end

    local lobbyMusic = SoundService:FindFirstChild("LobbyMusic")
    local teamSelector = SoundService:FindFirstChild("TeamSelector")
    local fightMusic = SoundService:FindFirstChild("FightMusic")

    if lobbyMusic and lobbyMusic.Playing then
        self:FadeSound("LobbyMusic", 0, 1)
    end

    if teamSelector and teamSelector.Playing then
        self:FadeSound("TeamSelector", 0, 1)
    end

    if fightMusic and fightMusic.Playing then
        self:FadeSound("FightMusic", 0, 1)
    end

    self.teamSelectionComplete = nil
end

return GameSystem
1 Like

Im pretty sure it is 100% safe, if you really are worried about it, then you can make a serverstorage module holding it this way client has no access.

3 Likes

Yes, I could do that—I’ve actually done it before with other modules (not related to match, but for things like gems, characters, abilities, etc…). However, I just wanted to be sure about this so I don’t end up having to create a bunch of separate ModuleScripts “unnecessarily”.

As stated in the documentation it says that it will only return true if it is the server, I am assuming roblox has taken multiple measures to prevent exploiting this.

1 Like

It's important to know that return values from ModuleScripts are independent with regards to Scripts and LocalScripts, and other environments like the Command Bar. Using require() on a ModuleScript in a LocalScript will run the code on the client, even if a Script did so already on the server. Therefore, be careful if you're using a ModuleScript on the client and server at the same time, or debugging it within Studio. Module Script Documentation

It is safe, the returned module script when you require it on the server, and on the client, is different. So Run service should function as expected. What you really need to consider, is if you want the client to have access to the functions in that module script, if that can affect other users’ experience. If yes, consider separating the server and client logic.

2 Likes

That’s exactly what I want to avoid—the “If yes” scenario. I’m trying to prevent any issues before publishing, and I want to make sure there’s no risk before going ahead and creating separate modules.

I appreciate you checking the documentation, but I’ll wait for a more definitive answer—ideally from someone who has actually seen a matchmaking game developed this way.

I don’t know. I personally wouldn’t hold up your development over something this small. Even if it is possible to exploit which is very unlikely. I assume Roblox took caution by only allowing the server to check if it is the server, You would need some serious level 8 executor in order to bypass this. But if you generally do not trust it i would just store it in ServerStorage. Don’t hold up your development / flow over this lol

1 Like

Yeah, you’re right—I’ll go ahead and separate the code, keeping the critical parts on the server. If no other replies come in, I’ll mark yours as the solution. Thanks!

1 Like

Unless you’re not worried of exploiters being able to see the bytecode, then i suppose it’s fine.
From the looks of the script, it does appear to hold some critical functions, which can allow anyone with decompilers to possibly find bugs to abuse.

But regarding what you said about “decompiling the code to find bugs and exploit them”—would that only affect things on their own client, or could it impact the entire game? How exactly could they do that?

No, it’s not entirely secure, but it comes down to what the script will try and do if the value is spoofed.

Exploiters can hook RunService.IsServer:

hookfunction(game["Run Service"].IsServer, newcclosure(function(...)
    --fancy logic to check for your script
    return true
end))

But then again, this shouldn’t be a big issue if the script will error when it’s not on the server and it tries to run server-only things. They can already decompile it to view the source, it’s not like having it error will do them any good considering the return value of the module is cached. Since the module modifications won’t replicate, all they are doing is making it error on their machine.

If the client needs to access data from the module but you want to hide what they don’t need, you could always split it into two modules and have the client module request data from the server one through networking.

1 Like

While it can only affect things in their clients, mind you that exploiters are willing to find anything that is abusable in order to have some kind of advantage against other players, including bugs.
Since it includes server code in the same module, they will be able to see what is the server is doing from that module alone, making their jobs easier to find a vulnerability.

Sure, they won’t be able to get the exact code you wrote, since Roblox does turn the code into bytecode that the engine can easily read, but then again, exploiters are capable of reversing the bytecode into a more readable code. (though not exactly as the original code)

1 Like

It is safe and accepted practice to call RunService:IsServer() inside a ModuleScript in ReplicatedStorage to prevent client and server logic from being intermingled. Because ReplicatedStorage is visible to both the client and server, the ModuleScript runs based on where it is being called (client or server), and IsServer() correctly checks that location.

Recommendations:

  • Keep Sensitive Logic on the Server: Your GameSystem.Server functions (like Initialize and TeleportPlayersToSpawns) are server-safe only

  • Validate RemoteEvents: Always validate inputs in server-side OnServerEvent handlers to guard against exploits (e.g., invalid team choices).

  • No Need to Restructure: Your setup is good. Putting the ModuleScript in ReplicatedStorage and using IsServer() is the norm for shared logic

1 Like

Alright, in that case—looking at the code as it is, would there be any actual chance of it being exploited?

And in your opinion, what would be the best approach? Should I keep it this way or go ahead and separate the modules?

Yeah, that’s exactly what worries me—their dedication to finding something to exploit. But thanks for the clarification!

1 Like

Yeah, I think I’ll go ahead and move the critical logic to ServerStorage instead.

The code reveals a lot about how your game server actually works, which leaves exploiters a much easier path to finding exploits. I’d separate them into two.

1 Like

Exactly—that was precisely what was concerning me about keeping the logic so easily accessible to exploiters. I’ll be making those changes now. Thanks!!!

2 Likes
    • RunService:IsServer(): Safe, no exploit risk. Reliably distinguishes client/server logic in ReplicatedStorage ModuleScript.

RemoteEvent Constraints:

  • TeamSelection: Could be spammed. Add debounce to limit rapid firing

  • CastThemeVote: Risk unless VotingSystem.Server validates votes (i.e., multiple/invalid votes). Robust server-side validation

  • Information transmitted through SyncVoteTally/ThemeSelected is client-readable. Send only necessary information

Client-Side: Your client-side code (audio/UI) is safe, as it’s cosmetic and doesn’t alter game state

  • Best Solution
  1. Keep Current Setup: It’s okay and normal to use IsServer() in a shared ModuleScript. You don’t have to separate the modules
  • Improvements:
  1. Add debounce to TeamSelection OnServerEvent (e.g., 1 second cooldown per player)

  2. Make sure VotingSystem.Server verifies all voting inputs (e.g., a single vote per player, valid themes)

  3. Only send necessary data to clients through RemoteEvents to avoid information leakage

1 Like