How to script a game server from scratch [from a Senior Engineer] [Tutorial]

Hello, I’m a senior software engineer with 11 years of coding under my belt. I’m relatively new to the Roblox ecosystem, as I started 1.5 years ago, but I think this gives me a unique perspective on Roblox Scripting. As a disclaimer, I’m not saying my way of doing things is the best or the only way of doing things, it’s just a way.

Let’s go ahead and jump into it. First, we’re going to start with the dependencies I use in my Roblox games.

Dependencies

I’m going over dependencies first because I believe these are incredibly useful tools to have in your toolkit when building any game. I’ll try to keep it brief. All of them are free except for VFXForge.

  • ProfileStore: This is a must have when building games. It integrates with Roblox Datastores for you, solving many problems out of the box. Most importantly, it provides an extremely simple API/way of interacting with Player data.
  • Signal: A replacement for BindableEvents that is more performant and allows you to pass through native Luau objects without losing data. Useful for keeping systems decoupled and implementing complex features.
  • Jolt: A simple networking library, it’s basically just a small wrapper around RemoteEvents. Provides some optimizations, but more importantly makes them a bit easier to use without needing to maintain a folder of RemoteEvents in Studio.
  • VFXForge: this one is the least necessary of the four listed. You can use it to play visual effects, but it has a bunch of additional features not native to Roblox such as mesh VFX. I sort of included it as bonus for people who plan on making a VFX-heavy game.

Now that I’ve went over which dependencies I use, let’s jump into the codebase architecture i use.

Codebase Architecture

To understand how to structure your code, you first need to understand two things.

  • There are two types of scripts, Server Scripts (also known as Script) and Client Scripts (also known as LocalScripts.)
  • Server Scripts run on Roblox servers and should be the central ‘hivemind’ of our logic. Client Scripts run on the player’s device, such as their phone, when they’re playing the game. LocalScripts should be used primarily for visuals and/or displaying the game state sent by the server.

With that in mind, in Studio, Server Scripts run inside of ServerScriptService, and Client Scripts run inside of StarterPlayerScripts. Note these scripts can run in other places, however, I believe it simplifies things if we just stick to those two.

Here is how all of my games look in the Roblox Studio Hierarchy: from the code perspective

-- ServerScriptService
-- ----> Main.server.lua

-- StarterPlayer
-- ----> StarterPlayerScripts
-- --------> Main.client.lua

This is known as a monolithic codebase, because all of our gameplay’s logic will have a single entrypoint on the client and a single entrypoint on the server.

Each script will require any number of ModuleScripts it needs, which may be a lot depending on your game. I personally follow patterns from the Go programming language, and I put all of my modules in a folder called internal. For the client, this folder would be in ReplicatedStorage, for the server, ServerStorage.

Code Lifecycle

It’s also important to understand the lifecycle of our code. In the real world, what happens is the Roblox Server will start up, and executes any script in ServerScriptService exactly once. Subsequently, clients (players) will start connecting to it. The client will execute any script in StarterPlayerScripts exactly once, shortly after they join the game.

Server Scripting - Data Model

First, I want to encourage folks to read this separate tutorial I wrote which is a deep dive on ProfileStore. It explains more in-depth how ProfileStore works and how to integrate with it.

The first thing I do when creating any game is that I write what data I need to keep track of. This is going to depend on the game you’re making and will definitely change as you’re building out your game, but defining it up front is necessary for the database integration.

In this case, I’m going to keep the definition simple.

--!strict
-- DataTemplate.lua
local ServerStorage = game:GetService("ServerStorage")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ProfileStore = require(ServerStorage.internal.data.Dependencies.ProfileStore)

export type Profile = ProfileStore.Profile<PlayerDataTemplate>

-- Define the type so we get type checking for our profile data.
export type PlayerDataTemplate = {
    Version: number,
    XP: number
}

-- The actual template that player data is initialized to when a player joins your game for the first time.
local PlayerDataTemplate: PlayerDataTemplate = {
    Version: 1,
    XP: 0,
}

return PlayerDataTemplate

Server Scripting - Main Server Script + Player Lifecycle

Now I’m going to go over what I call the Player Lifecycle. There are a few events that we care about.

  • Player joins the game - this fires when any player joins your game, one time.
  • Player leaves the game - fires when any player leaves the game.
  • Player client has loaded - fires when the client is ready to accept messages.
  • Player dies
  • Player respawns
  • Player’s avatar has loaded - useful to differentiate this from respawn events when applying custom accessories or auras.

Attached is an example of a barebones server script that hooks into all of these events, with comments.

--!strict
local Players = game:GetService("Players")
local ServerStorage = game:GetService("ServerStorage")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local CombatManager = require(ServerStorage.internal.combat.CombatManager)
local DataTemplate = require(ServerStorage.internal.data.DataTemplate)
local ProfileStore = require(ServerStorage.internal.data.Dependencies.ProfileStore)
local ProfileLoader = require(ServerStorage.internal.data.ProfileLoader)
local ProfileManager = require(ServerStorage.internal.data.ProfileManager)
local Procs = require(ServerStorage.internal.simulation.Procs)
local Jolt = require(ReplicatedStorage.internal.networking.Jolt)
local ClientLoadedEvent = Jolt.Server("ClientLoaded")

type ProfileStore = ProfileStore.ProfileStore<DataTemplate.Profile>
type Profile = DataTemplate.Profile

-- Instantiate your profile store.
local PlayerProfileStore = ProfileStore.New("PlayerData", DataTemplate) :: ProfileStore

-- Fires when the player first joins our game.
local function OnPlayerAdded(player: Player)
    local profile = ProfileLoader.loadAsync(player, PlayerProfileStore)
    if profile then
        -- Using Signal, we fire an event saying the Profile has loaded.
        Procs.Signals.OnProfileLoaded:Fire(player)
    end
end

-- Fires every time the player respawns.
local function OnPlayerRespawn(player: Player, character: Model)
    print(player.Name .. "has respawned!")
end

-- Fires when the client has finished loading.
local function OnPlayerLoad(player: Player)
    print("Player client has loaded, and is ready to receive data.")
    -- usually, I will send RemoteEvents to the client with their initial data here.
    -- for example, with an inventory, I'd send the client their entire inventory.
end

-- ClientLoaded executes once when the client has loaded all of its modules.
-- This is necessary because when using Jolt, the client needs to register
-- all of its RemoteEvent listeners before the server can send it any data.
ClientLoadedEvent:Connect(function(player: Player)
    local profile = ProfileManager.getPlayerProfile(player)
    if not profile then
        -- The profile is not loaded yet. Thus, we wait for it to load.
        local signal
        signal = Procs.Signals.OnProfileLoaded:Connect(function(player: Player)
            OnPlayerLoad(player)
            if signal then
                signal:Disconnect()
            end
        end)
    else
        OnPlayerLoad(player)
    end
end)

-- Fires when the player dies.
local function OnPlayerDied(player: Player)
    print(player.Name .. " has died!")
end

-- We have to get the Humanoid in order to connnect to the .Died event.
local function ConnectOnPlayerDied(player: Player, character: Model)
    local humanoid = character:WaitForChild("Humanoid") :: Humanoid?
    if not humanoid then
        return
    end
    humanoid.Died:Connect(function()
        OnPlayerDied(player)
    end)
end

-- Fires when the player joins our game.
local function OnPlayerJoin(player: Player)
    task.spawn(OnPlayerAdded, player)
    -- Fires when the character spawns.
    player.CharacterAdded:Connect(function(character: Model)
        ConnectOnPlayerDied(player, character)
    end)
    -- Fires when the character's avatar has fully loaded.
    player.CharacterAppearanceLoaded:Connect(function(character)
        OnPlayerRespawn(player, character)
    end)
end

-- Fires when the player leaves the game.
local function OnPlayerRemoving(player: Player)
    ProfileManager.removeProfile(player.UserId)
end

Players.PlayerAdded:Connect(OnPlayerJoin)
Players.PlayerRemoving:Connect(OnPlayerRemoving)

-- Execute OnPlayerJoin for players that were in the server before we hooked it up to the event listener 3 lines above this.
for _, player in Players:GetPlayers() do
    OnPlayerJoin(player)
end

-- Start all of your ModuleScripts, which usually involves making them listen for some kind of RemoteEvent.
-- Note: this could be done in a loop when you have multiple, but isn't necessary. It's up to you.
CombatManager.start()

Bonus: The Main Client Script

I mostly wanted to cover the server side of things, but I think it will be helpful to see the client. My client is a set of modules that execute once when they’re required. After all modules have executed, meaning they’re ready to listen for RemoteEvents from the server, we fire a RemoteEvent to the server signaling that we’re ready. Attached is an example script from a game I’m making to demonstrate the pattern.

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Jolt = require(ReplicatedStorage.internal.networking.Jolt)
local ClientLoadedEvent = Jolt.Client("ClientLoaded")

local _ = require(ReplicatedStorage.internal.ui.LobbyScreen)
local _ = require(ReplicatedStorage.internal.ui.LoadGUI)
local _ = require(ReplicatedStorage.internal.action.DetectUserInputType)
local _ = require(ReplicatedStorage.internal.ui.EnableGUIOnSpawn)
local _ = require(ReplicatedStorage.internal.party.PartyClient)
local _ = require(ReplicatedStorage.internal.action.NoFall)
local _ = require(ReplicatedStorage.internal.action.LockOnClient)
local _ = require(ReplicatedStorage.internal.action.UtilitySpells)
local _ = require(ReplicatedStorage.internal.vfx.VFXWarmup)
local _ = require(ReplicatedStorage.internal.ui.UIAnimations)
local _ = require(ReplicatedStorage.internal.vfx.TurnToNight)
local _ = require(ReplicatedStorage.internal.ui.ReportTimezone)
local _ = require(ReplicatedStorage.internal.skills.XPOrbs)
local _ = require(ReplicatedStorage.internal.ui.RenderEnemies)
local _ = require(ReplicatedStorage.internal.sound.SoundClient)
local _ = require(ReplicatedStorage.internal.action.DanceClient)
local _ = require(ReplicatedStorage.internal.skills.ClientSkillsUI)
local _ = require(ReplicatedStorage.internal.class.ClientClassUI)
local _ = require(ReplicatedStorage.internal.skills.XPClient)
local _ = require(ReplicatedStorage.internal.ui.FavoriteClient)
local _ = require(ReplicatedStorage.internal.action.DashClient)
local _ = require(ReplicatedStorage.internal.vfx.CameraShakeClient)
local _ = require(ReplicatedStorage.internal.inventory.CustomBackpackGui)
local _ = require(ReplicatedStorage.internal.vfx.AggroClient)
local _ = require(ReplicatedStorage.internal.quests.QuestClient)
local _ = require(ReplicatedStorage.internal.action.BlinkClient)
local _ = require(ReplicatedStorage.internal.mana.FluxClient)
local _ = require(ReplicatedStorage.internal.damage.DisplayDamageClient)
local _ = require(ReplicatedStorage.internal.status.DisplayStatusEffectsClient)
local _ = require(ReplicatedStorage.internal.ui.DisplaySystemMessageClient)
local _ = require(ReplicatedStorage.internal.inventory.DropDisplayClient)
local _ = require(ReplicatedStorage.internal.ui.InventoryAndCraftingAndShopClient)
local _ = require(ReplicatedStorage.internal.mutation.MutationCapuleClient)
local _ = require(ReplicatedStorage.internal.sound.SilenceFootsteps)
local _ = require(ReplicatedStorage.internal.mana.Refill)
local _ = require(ReplicatedStorage.internal.spells.ClientSpellCast)
local _ = require(ReplicatedStorage.internal.environment.Destruction)
local _ = require(ReplicatedStorage.internal.pets.PetController)
local _ = require(ReplicatedStorage.internal.ui.Notifications)
local _ = require(ReplicatedStorage.internal.action.PreventFallOffMap)
local _ = require(ReplicatedStorage.internal.spells.OrbTurretController)
local _ = require(ReplicatedStorage.internal.ui.InCombatUI)
local _ = require(ReplicatedStorage.internal.vfx.PlayerAuras)
local _ = require(ReplicatedStorage.internal.skills.ClientSkillPointsGui)
local _ = require(ReplicatedStorage.internal.ui.CustomCursor)
local _ = require(ReplicatedStorage.internal.ui.HideChat)
local _ = require(ReplicatedStorage.internal.inventory.ClientEquipmentManager)
local CmdrClient = require(ReplicatedStorage:WaitForChild("CmdrClient"))

CmdrClient:SetActivationKeys({ Enum.KeyCode.F2 })

ClientLoadedEvent:Fire()

Conclusion

I hope this was helpful! I wanted to keep it somewhat brief while giving working examples to start off of, but I’m aware I didn’t explain every detail. if you have any questions, feel free to let me know!

6 Likes

This is a perfect usecase for a module loader, I cannot imagine dealing with this :joy:

eg. for moduleScript in allModules do require(moduleScript) end

2 Likes

Yep, I doubt you read all of the tutorial but I do mention a module loader in there! I prefer to keep things explicit

Explicit is better for tracking dependencies, but that client list is getting pretty hard to manage manually as the project grows. How do you handle circular dependencies if you start moving away from that manual require pattern?

It really costs nothing to manage. Anytime I need to add new client functionality, I just add a new require. My IDE (i use Rojo + VSCode) auto imports the require, I don’t even have to type the full path. More abstraction is not always good, though if you prefer module loaders there’s nothing wrong with that.

edit: I’ve never run into a circular dependency because most of my modules are isolated and do not require each other. Modules that are required by other modules are also typically isolated and don’t import other modules, making circular dependencies unlikely. If I ran into one, I’d just decouple them by splitting them into more modules or using a Signal.

The auto-import makes it less of a chore, but it still doesn’t solve the issue of having to manually touch that main file every single time you create a new module. It feels like extra work just to keep the entrypoint updated.

An important mindset to take on is that there is no singular right way of doing things, only tradeoffs. When I add new functionality, it’s just one line of code to the main script. That’s extremely trivial, and to me personally avoiding that one line of code isn’t worth the tradeoff of losing explicit ordering and determinism in script execution order. I’m not saying my way is explicitly “correct,” just that I prefer these tradeoffs.

The determinism argument is fair, but it’s still a manual bottleneck. If you’re already using Rojo and VSCode, a simple task or script to scan the folder and update that list would be much more efficient than manually adding lines.

At least categorize the requires :sob::sob:

-- UI
local _ = require(ReplicatedStorage.internal.ui.LobbyScreen)
local _ = require(ReplicatedStorage.internal.ui.LoadGUI)
...
-- VFX
local _ = require(ReplicatedStorage.internal.vfx.VFXWarmup
...
-- Action
local _ = require(ReplicatedStorage.internal.action.NoFall)
...

Or as previously mentioned a module loader. I’d rather prefer writing client loader once and hardly ever touch it again, unless absolutely necessary.

I mean, you started off pretty strong. As soon as I seen the multi-requires I instantly thought “Module Loader”, it maintains cleanliness and readability here.

Outside of this not too bad, it’s a slight bit to learn from.

The cleanliness argument is fine for small projects, but a loader scales much better without needing constant manual updates to an entrypoint. It’s less about the ‘learning curve’ and more about just not wanting to touch the same file every time you add a single feature.

I don’t find anything clean about requiring the way OP did, lol. It sort of maintains best practice tying back into readable/organized code.

The ‘best practice’ argument is a bit vague here. If you mean it makes dependencies easier to track, sure, but manually managing a list of requires is just extra boilerplate that a simple loader handles automatically.

That’s a misread of what I meant by “best practice.” I wasn’t defending manual requires over a loader — I already agree a loader is the better call here, for the exact reason you mentioned (not touching the entry-point every time you add a system).

What I was calling out as not “clean” was the chain of requires. It’s a best practice issue rather than me speaking on the “manual requires > loader” argument.

Then we’re on the same page. The dependency chain is definitely a mess if you’re not using a loader to handle it.

2 Likes