Verification: Static review complete; Studio/device tests unverifiedLast verified: 2026-10-03Prerequisites: Remotes and validation

11 Modules and configuration

Before you start and what you will change

Finish the working boost in Remotes and validation. Save a checkpoint copy of the place before refactoring. You will move tuning values into a ModuleScript and replace the existing BoostServer's contents with an equivalent implementation. The station, client UI, remote names, and collectible script remain as they are.

This is a small refactoring exercise with a deliberately narrow success criterion: players should observe the same behavior, while future tuning requires editing one clearly named file. A module is useful when it gives something one owner or a testable interface. Merely moving every ten lines into another file would not improve this example.

Understand the execution model

A ModuleScript returns one value to require, commonly a table of configuration or functions. Within one execution environment, subsequent requires reuse its result. A module required on the server and a client executes separately in those environments; mutable tables do not become a networked shared object. See Reuse code.

A ModuleScript in ServerScriptService is appropriate here because only the server consumes authoritative tuning. If a future interface needs to display duration or remaining cooldown, send the display data deliberately, or put genuinely shared non-secret definitions in ReplicatedStorage. Do not move secret keys or server-only logic into replicated storage to make a client require succeed.

Create the configuration module

Stop Play. Add a ModuleScript directly under ServerScriptService and name it BoostConfig. Keep the existing Script named BoostServer.

ServerScriptService
  Collectibles               existing Script
  BoostConfig                new ModuleScript
  BoostServer                existing Script, contents replaced below

Paste this complete file into BoostConfig:

Download 11-boost-config.module.luau

-- ModuleScript: ServerScriptService > BoostConfig
local Config = {
    RequiredCoins = 3,
    Radius = 12,
    Speed = 28,
    Duration = 5,
    Cooldown = 20,
    RequestInterval = 0.5,
}

local function finiteNumber(value)
    return typeof(value) == "number"
        and value == value and math.abs(value) < math.huge
end
for key, value in pairs(Config) do
    assert(finiteNumber(value), key .. " must be a finite number")
end
assert(Config.RequiredCoins >= 0 and Config.RequiredCoins % 1 == 0,
    "RequiredCoins must be a nonnegative integer")
assert(Config.Radius > 0 and Config.Radius <= 30, "Radius must be in (0, 30]")
assert(Config.Speed > 0 and Config.Speed <= 40, "Speed must be in (0, 40]")
assert(Config.Duration > 0, "Duration must be positive")
assert(Config.Cooldown >= Config.Duration, "Cooldown must cover Duration")
assert(Config.RequestInterval >= 0.1, "RequestInterval must be at least 0.1")
return table.freeze(Config)

The numeric checks reject NaN, infinity, strings, and values outside this tutorial's intended ranges. These limits are design choices for this small game, not universal Roblox limits. For example, Radius at most 30 keeps this station local; another design might reasonably use a different bound.

table.freeze stops ordinary writes to the returned table. It is shallow: if this table contained nested tables, freezing the outer table would not freeze those nested tables. All our fields are primitive numbers, so shallow freezing is sufficient. The relevant semantics are documented in the Luau standard library.

Replace the existing server implementation

Replace all the contents of the existing ServerScriptService.BoostServer with the file below. Do not add a second Script named BoostServerModular. 11-boost-server-modular.server.luau is the downloadable file's name, not an additional runtime object name. Keep the lesson 10 BoostClient unchanged.

Download 11-boost-server-modular.server.luau

-- Script: ServerScriptService > BoostServer
-- Lesson 11: replace the existing BoostServer contents with this whole file.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Config = require(script.Parent:WaitForChild("BoostConfig"))

local station = workspace:WaitForChild("BoostStation", 10)
assert(station and station:IsA("BasePart"), "Create Workspace.BoostStation Part")
assert(not ReplicatedStorage:FindFirstChild("BoostRemotes"), "Run only one BoostServer")
local remotes = Instance.new("Folder")
remotes.Name = "BoostRemotes"
local request = Instance.new("RemoteEvent")
request.Name = "RequestBoost"
request.Parent = remotes
local feedback = Instance.new("RemoteEvent")
feedback.Name = "BoostFeedback"
feedback.Parent = remotes
remotes.Parent = ReplicatedStorage

local lastRequest = {}
local nextAllowed = {}
local active = {}
local function reply(player, requestId, ok, message)
    feedback:FireClient(player, requestId, ok, message)
end

request.OnServerEvent:Connect(function(player, action, requestId)
    local now = os.clock()
    if now - (lastRequest[player] or -math.huge) < Config.RequestInterval then
        return -- Do not multiply a flood by sending a reply for every rejected request.
    end
    lastRequest[player] = now
    if typeof(requestId) ~= "number" or requestId % 1 ~= 0
        or requestId < 1 or requestId > 1000000000 then
        return -- Correlation metadata must also be bounded and well-formed.
    end
    if typeof(action) ~= "string" or action ~= "speed" then
        reply(player, requestId, false, "Unknown request")
        return
    end

    local character = player.Character
    local humanoid = character and character:FindFirstChildOfClass("Humanoid")
    local root = character and character:FindFirstChild("HumanoidRootPart")
    if not humanoid or humanoid.Health <= 0 or not root or not root:IsA("BasePart") then
        reply(player, requestId, false, "Respawn before requesting a boost")
        return
    end
    local distance = (root.Position - station.Position).Magnitude
    if not (distance <= Config.Radius) then -- Also rejects NaN distances.
        reply(player, requestId, false, "Move closer to the green boost station")
        return
    end
    local leaderstats = player:FindFirstChild("leaderstats")
    local coins = leaderstats and leaderstats:FindFirstChild("Coins")
    if not coins or not coins:IsA("IntValue") or coins.Value < Config.RequiredCoins then
        reply(player, requestId, false, "Collect " .. Config.RequiredCoins .. " coins first")
        return
    end
    local remaining = (nextAllowed[player] or 0) - now
    if remaining > 0 or active[player] then
        reply(player, requestId, false, "Boost cooling down; try again shortly")
        return
    end

    -- No yielding between final validation and committing the state change.
    nextAllowed[player] = now + Config.Cooldown
    local token = {}
    active[player] = token
    local previousSpeed = humanoid.WalkSpeed
    humanoid.WalkSpeed = Config.Speed
    reply(player, requestId, true, "Boost active for " .. Config.Duration .. " seconds")

    task.delay(Config.Duration, function()
        if active[player] ~= token then return end
        active[player] = nil
        if player.Character == character and humanoid.Parent
            and humanoid.WalkSpeed == Config.Speed then
            humanoid.WalkSpeed = previousSpeed
        end
        if player.Parent == Players then
            reply(player, requestId, true, "Boost ended; wait for the cooldown before reusing")
        end
    end)
end)

Players.PlayerRemoving:Connect(function(player)
    lastRequest[player] = nil
    nextAllowed[player] = nil
    active[player] = nil
end)

Why validate configuration at startup

A mistaken negative duration or a cooldown shorter than the active effect can turn a harmless tuning edit into confusing behavior. Failing at startup with a specific assertion exposes the faulty input before a player reaches that branch. The module completes before the server creates remotes, so a rejected configuration leaves no partially working boost endpoint.

This is also a lesson about dependencies. BoostServer depends on BoostConfig. BoostConfig has no dependency back on BoostServer, does not connect to events, and does not spawn tasks. Keeping the module free of gameplay side effects makes it easier to reason about when the boost system begins operating. Circular require chains obscure initialization and should be removed rather than “fixed” with arbitrary waits.

As the project grows, a separate BoostService module could expose TryActivate(player) and return an explicit result. That would let a button and a proximity interaction use the same business rule. Do not implement that extra layer until there is a real second caller or testability benefit. Configuration extraction alone is enough for this lesson.

Verify unchanged behavior and intentional failures

Run all seven acceptance tests from lesson 10. The default configuration should produce identical thresholds, duration, and cooldown. Compare the source before and after: the key change is the require statement replacing the inline Config table.

Next, stop Play, change RequiredCoins to 2, and start a fresh test. Two collected coins should now qualify. Restore it to three afterward. Then temporarily set Cooldown to 2 while Duration is five. Startup should report Cooldown must cover Duration, and the boost UI may wait for remotes that were intentionally never created. Restore the valid configuration before continuing.

Finally, try adding Config.Speed = 999 immediately after require as a temporary test. The frozen table should reject the assignment. Remove this line. No source in this manual was executed in Studio during authoring; these are verification steps for your own place.

Troubleshooting

  • Waits for BoostConfig: inspect the exact name and parent; it must be a sibling of BoostServer.
  • Require returns the wrong thing: use a ModuleScript with a final return, not a regular Script.
  • A tune appears unchanged: stop and start a new test after editing the source. Do not confuse a runtime copy or a cached require result with a fresh session.
  • Duplicate remotes assertion: you added the replacement as another Script. Keep one BoostServer and remove the duplicate source object.
  • Assertion after a tune: read the named constraint before changing validation. Decide whether the design really needs a new range.

Exercises

  1. Add a positive FeedbackSeconds setting only when you have a concrete server use for it; otherwise explain why an unused option adds confusion.
  2. Sketch a pure function CanAfford(balance, required) with tests for below, equal, and above the threshold. Keep Roblox Instance access out of that function.
  3. Explain why freezing a replicated module's client table would not protect the server from fabricated remote requests.

Sources and navigation

Last checked 2026-10-03: ModuleScript API, reuse and require behavior, Luau library and table.freeze.

Previous: 10 Remotes and validation · Next: 12 Co-op rounds

Related: 03 Luau for coders · 09 Client-server boundary · 16 Debugging and performance

Progress and help notes

Nothing is sent automatically. Copying happens only when you press the button.

Sources