Is This a Good Approach for a Roblox Module Loader?

Is this a good approach for a Roblox module loader?

Basically, each of my services has an Init function that contains its main logic and lifecycle setup, such as connecting RemoteEvent listeners with the actual logic inside.

All dependencies are also required outside of the Init function.

The loader works in three stages:

  1. Require all modules.
  2. Call Init() for every module.
  3. Only then start listening for player events.

Here is the loader:

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

local DataManager = require(game.ServerScriptService.UniverseServer.Data.DataManager)

local folders = {
	ServerScriptService.UniverseServer.Services,
	ServerScriptService.UniverseServer.Systems,
	ServerScriptService.UniverseServer.DeveloperProducts
}

local loadedModules = {}
local playerHandlers = {}
local characterHandlers = {}
local playerRemovingHandlers = {}

local function safeCall(moduleName, fn, ...)
	local success, err = pcall(fn, ...)

	if not success then
		warn(("[Loader] %s error: %s"):format(moduleName, err))
	end
end

local rawModules = {}

for _, folder in ipairs(folders) do
	for _, module in ipairs(folder:GetDescendants()) do
		if module:IsA("ModuleScript") and not loadedModules[module] then
			loadedModules[module] = true

			local success, moduleScript = pcall(require, module)

			if not success then
				warn(("[Loader] Failed to require %s: %s"):format(
					module:GetFullName(),
					moduleScript
				))
			elseif typeof(moduleScript) == "table" then
				table.insert(rawModules, {
					Name = module.Name,
					Script = moduleScript
				})
			end
		end
	end
end

for _, item in ipairs(rawModules) do
	local mod = item.Script

	if typeof(mod.Init) == "function" then
		safeCall(item.Name .. ".Init", function()
			mod:Init()
		end)
	end

	if typeof(mod.SetupPlayer) == "function" then
		table.insert(playerHandlers, function(player)
			mod:SetupPlayer(player)
		end)
	end

	if typeof(mod.SetupCharacter) == "function" then
		table.insert(characterHandlers, function(player, character)
			mod:SetupCharacter(player, character)
		end)
	end

	if typeof(mod.PlayerRemoving) == "function" then
		table.insert(playerRemovingHandlers, function(player)
			mod:PlayerRemoving(player)
		end)
	end
end

local function onPlayerAdded(player)
	for _, handler in ipairs(playerHandlers) do
		task.spawn(function()
			safeCall("SetupPlayer", handler, player)
		end)
	end

	player.CharacterAdded:Connect(function(character)
		for _, handler in ipairs(characterHandlers) do
			task.spawn(function()
				safeCall("SetupCharacter", handler, player, character)
			end)
		end
	end)

	if player.Character then
		for _, handler in ipairs(characterHandlers) do
			task.spawn(function()
				safeCall("SetupCharacter", handler, player, player.Character)
			end)
		end
	end
end

DataManager.PlayerLoaded.Event:Connect(onPlayerAdded)

Players.PlayerRemoving:Connect(function(player)
	for _, handler in ipairs(playerRemovingHandlers) do
		task.spawn(function()
			safeCall("PlayerRemoving", handler, player)
		end)
	end
end)

Is there anything fundamentally wrong with this architecture, or are there issues that could cause problems later as the project grows?

1 Like

It’s unknown how many other “handlers” you will want to add in the future.
So potentially the file grows indefinitely.

Removal of handlers is also problematic, especially modules cleanup. You need to use text search for that and script analysis will not help.

Creating helper/utility modules (as I understood this is what you want to achieve here) is admirable and not a problem.
But not this way.
Instead:

  1. Create a helper module, export types from it (so script analysis will work).
  2. Require it where it needed

With this approach:

  • it’s easy to track where the module is in use
  • it’s easy to change module interfaces and propagate changes across the code base
  • no need to place modules that require the helper in weird locations

Keep different helpers/utilities in different modules, so it will be easier to add/remove them.

1 Like

There’s nothing inherently wrong here. But I’d say after making a framework/module loader, always battle test it by making games - all the limitations and benefits will show themselves then.

And as @af_2048 mentioned, scope creep could be a bad problem. Don’t let yourself fall into the trap of endlessly centralising every event, which I once thought of doing.

1 Like

I see, but I’m not planning to add more handlers to the services and controllers. As for events, I really wanted to keep a single listener because I feel like it’s better than having each module connect its own listener, although I’m not entirely sure about that.

I think I’ll stick with this approach for now and see how it performs in an actual game once I release it. If something goes wrong, I’ll make changes then.

1 Like

Right now, I’m not planning to add any more handlers. And as I said, I’m not really sure why I would require each module where it’s needed, because all my modules do in their Init functions is initialize their main logic. SetupPlayer sets up the player for that specific service, and SetupCharacter handles any character-related changes for that module.

Also, SetupPlayer is only called once the player’s data has been loaded, and the same applies to SetupCharacter.

1 Like

This approach isnt exactly wrong but what your doing is essentialy trying to make a hub of sorts for your other modules but then not adding functions properly to said hub/main handler,

im working in something that uses a similar kind of thing, make all modules etc accessable, but i did it as a single module that has its own functions as well as acting as a shortcut to others, eg datastore, currently itd have sperate functionality from everything else, but if stored this on a metatable that has access to your other modules you can use the methods easier on its contents.

The main benifit of metatable is that you can store data as well as add functions etc, so to make the most of this you would really want a main Handler, then sub handlers/modules that the main handler can use freely, as it all runs thro the hub you can use that to your advantage eg if i had playerdata and a remotefunction so the client can request their data, normally this would mean setting up the remote or using ur module, then accessing whatever contains the playerdata, however using something like this i can do say:

local Hub = require(game...)
--Assume Hub already used datastore etc
Hub:BindRemote("PlayerData",function(Player,Data)
   return Hub["PlayerData"][Player]
end)

then everything runs off similar context + format etc so its more fluent to use vs individual modules, this also means you hub would need type exports for every module but this makes it a very nice tool as it centralises it

Now that im on my pc i can give a better example of how this pieces together

local Hub = HubMod:GetHub()
local Audio = Hub:GetAudio()
local Rays = Hub:GetRayHandler()
local ContentProvider = Hub:GetContentProvider()

game.Players.PlayerAdded:Connect(function(Player)
	Hub.PlayerData[Player] = Hub:GetDataSlot():Load()
end)

Hub:InitSerialBus()

Hub.SerialBus.WaitAndBind("PlayerData",function(Player,Data)
	return Hub.PlayerData[Player]
end)
Hub.SerialBus.WaitAndBind("PewPew",function(Player,Data)
	Hub.RayHandler:NewRay("Test")
	local Result = Hub.RayHandler.CastRayBetweenPoints("Test",Player.Character.Position,Data["TargetPos"])
	if Result and Result.Instance then
		Result.Instance:Destroy()
	end
end)

This does similar to what your doing but its more structured rather than looping, this has some advantages as you may not need/want a handler for everything, or the extra events etc but still gives the option to do so for example.

function Hub:LoadObject(Object)
	if self["ContentProvider"] ~= nil then
		self["ContentProvider"]["TableOfObjects"] = {Object}
		self["ContentProvider"]:Load()
		return true
	end
end

I can then use Hub:LoadObject(Object) as opposed to going through the handler or in this case using the existing handler
or for quick audio sounds I can do say Hub:AddSound(“Pew”,121431), Hub:PlaySound(“Pew”,Gun)

overall id say its way more customisable like this vs just loading your modules as is and gets a little bit of added functionality

There is a bit of a drawback though, if you export types from the modules, your need to add the types again on the main handler/hub thing

Solid instinct — staged Require → Init → player hooks is the right shape (same idea as Knit/OwlKnit / Flamework-style lifecycles, just in plain Luau).

A few growth tips:

  1. Ordered Init
    Don’t rely on GetDescendants() order. Give modules an optional Priority or Dependencies list and sort before Init. Data/store modules before gameplay services.

  2. Keep remotes inside the service
    Having each service connect its own remotes in Init is fine and usually clearer than one mega-listener hub. The loader’s job is lifecycle, not becoming a god-object for every event.

  3. Start vs Init
    Common pattern:

    • Init() — create remotes, build tables, no cross-service calls
    • Start() — after every module Inited, wire cross-dependencies
      That avoids “service A Init needs service B which isn’t ready”.
  4. Character hooks
    Your SetupCharacter list is good. Also listen to CharacterRemoving and clean Maids so you don’t leak connections across respawns.

  5. Failure isolation
    Your safeCall is good — also mark a module as failed and skip its player hooks instead of half-starting it.

Example skeleton:

-- Service module
local AbilityService = {}
AbilityService.Priority = 50

function AbilityService:Init()
	-- remotes, tables only
end

function AbilityService:Start()
	-- safe to require other services
end

function AbilityService:SetupPlayer(player: Player)
end

return AbilityService

You’re not wrong for centralizing boot. Just keep the loader thin and services thick.

I’m not a big fan of this for a couple of reasons:

  • You’re coupling character/player setup with module initialization, which can lead to some unintended and weird behavior in the future, especially since nothing is ordered
  • Speaking of which, you seem to have no support for ordered/prioritized loading
  • You should add support for context-based initialization (client/server)
  • You’re “initializing” developer products, which should just be data
  • The cleanup feels a bit messy, I recommend using a cleanup module like Trove or Maid

There are some other small gripes, but at that point it’s less about design and more just nitpicks.

If you want to see how I bulk initialize modules, you can check out my code here for reference: Praxis/src/Packages/Initializer/init.luau at main · MiaGobble/Praxis · GitHub

(Also, is the reply above me AI slop? Sure looks like it)

1 Like

Hello, I appreciate your response, but let me break things down a bit.

I don’t really think I need ordered/prioritized loading for this type of game. It’s not a game where tons of highly complex systems interact with each other.

As for context-based initialization, that’s a good point. Right now, I actually have two different scripts that do almost the same thing on the server and the client, so I can definitely see the benefit of that approach.

Regarding the Developer Products folder you mentioned, it’s mostly there because I didn’t have a better idea of how to organize those products. Basically, there are some Developer Products that I can’t really consider a part of any specific system/service. I also have my own Developer Product service that handles purchases and supports registering a Developer Product with a specific handler.

That’s why I put those products into modules and register the products and their handlers inside Init. These modules also expose some public API, which allows me to, for example, add tiers through the admin panel.

As for coupling character/player setup with module initialization, I don’t really see much of an issue with it. It’s essentially the same PlayerAdded / CharacterAdded flow, except instead of having every module create its own connection, I handle those connections in one place and pass the required data to the respective modules.

Most of my SetupPlayer and SetupCharacter functions also require the player’s data to be ready beforehand. That’s why I listen to DataManager.PlayerLoaded.Event:Connect(onPlayerAdded) rather than directly to Players.PlayerAdded.

Also, do you think it’s worth having separate :Start() and :Init() functions? Honestly, I don’t really see much value in separating them in my current setup, so I’d like to hear what kinds of situations you find this useful for.

I’m also not really sure how to organize dependencies/ordering in a way that’s easy to maintain and change later. I’d appreciate any advice on how you usually approach this.

And finally, would it make sense to have an attribute on modules like ShouldBeGatheredByLoader, so the module loader only initializes modules that have that attribute enabled? Or is there a better way to handle this?

1 Like

I’ve never really found that to be particularly helpful for me.

You can read about how do I that here: Architecture | Oxomo-Coding-Conventions

I only see that being handy if you actually need it.

I was also wondering how to implement what I’m about to describe. Basically, I’m planning to turn the loader into a module that can work on both the client, to initialize client modules, and the server, to initialize server systems. So I was wondering how I could make the client start initializing only after the server has been fully initialized and has run :SetupPlayer() once for every module.

You could use an attribute on the module from the server when it’s loaded, then on the client wait for it to be enabled.

So, basically, I should get rid of task.spawn, wait for the for loop to finish, and then set an attribute on the module indicating that the server has been initialized, right?

And for the player, I should set a second attribute and listen for it as well, then wait until both attributes are present before initializing the client?

Yes

I don’t see why you would need this.

Certain systems add fields to the data. For example, settings:

-- Add missing settings
for settingName, settingData in pairs(SettingsConfig) do
    if settingsData[settingName] == nil then
        replica:Set({"SettingsData", settingName}, settingData.DefaultValue)
    end
end

-- Remove obsolete settings
for settingName in pairs(settingsData) do
    if SettingsConfig[settingName] == nil then
        replica:Set({"SettingsData", settingName}, nil)
    end
end

The client then generates the settings list using the values from the data.

Anyway, thank you! I really appreciate your help. I also made a new loader, which seems to work much better for me!

1 Like