Help with modularisation and one script architecture

Hello, this is my first post on the DevForum, hello.

Recently I’ve attempted to use a one script architecture with module scripts because I found it to be a great way to organise my project, but there’s barely any documentation on this whole thing and I am not sure if I am doing it correctly.


--[[
	The server initializer script used to get all the server-side modules
--]]


local modules = {} -- the modules table, where all the modulescripts are stored

-- Looping through the scripts descendants searching for moduleScripts, if moduleScript then add it to the table by requiring it, 
for i,obj in pairs(script:GetDescendants()) do
	if obj.ClassName == "ModuleScript" then
		modules[obj.Name] = require(obj)
	end
end

for i,obj in pairs(game:GetService("ReplicatedStorage").Modules.Shared:GetChildren()) do
	if obj.ClassName == "ModuleScript" then
		modules[obj.Name] = require(obj)
	end
end

-- Initialise all ModuleScripts by calling its init() function which should be in every module script
for name,module in pairs(modules) do
	if module["init"] then
		module.init(modules)
	end
end

So basically I have a Script and a LocalScript which require all of their respective side’s modules + shared modules and put them in a table. (shared modules means both for server and client)

Then the Script / LocalScript initialises all of the ModuleScripts by looping through them and calling their init() function which passes the modules table as a paramater so the modules have access to the other modules just by reference.

Notice how the Script and LocalScript do the same thing just requiring server-sided modules and client-sided modules respectively.

I could accomplish the same thing by using a Main ModuleScript which would have an init() function which would do the same thing but check if it’s client or server and then add the modules to that respectively. Like;

local main = {}

function main.init()
	local group
	local modules = {}
	
	if not game.Players.LocalPlayer then
		-- server
		group = game.ServerScriptService.Modules.Server 
	else 
		group = game.ReplicatedStorage.Modules.Client
	end
	
	for i,obj in pairs(group:GetChildren()) do
		if obj.Name == "ModuleScript" and obj.Name ~= script.Name then
			modules[obj.Name] = require(obj)
		end
	end
	
	for i,obj in pairs(game.ReplicatedStorage.Modules.Shared:GetChildren()) do
		if obj.Name == "ModuleScript" and obj.Name ~= script.Name then
			modules[obj.Name] = require(obj)
		end
	end
	
	for name,module in pairs(modules) do
		if module["init"] then
			module.init(modules)
		end
	end
end

return main

and then the Script and LocalScript would require that main module.

Also, how about loading screens? Putting a module under ReplicatedFirst would take out the purpose of ReplicatedFirst as it would have to wait for the LocalScript to res in and call the module, because modules dont run instantly, so the most optimal solution would probably be to create a LocalScript for loading screens instead, or have a loading screen inside of StarterGui.

Should I use a Main Module Script for initialising and then let the other scripts require the main module or should I let the other scripts do the initialising work?

(aka should I use the former or the latter)

Let me know what you think of this system, what you think of how I implemented it,and what should be improved.

So first, I think this post should go in #development-support:requests-for-code-feedback or #development-discussion, since this is basically asking for ways people architect their scripts.

Anyway, On my latest game, I am also using a one script architecture, but I separate it by its services client-side. This separates one local script into three for me, one for StarterGui, one for ReplicatedFirst, and one for StarterPlayerScripts. I might even add one in StarterCharacterScripts for weird custom effects. As long as the local scripts stay in their respective spheres of influence, maybe even communicate with BindableEvents, everything stays organized.
I have always been using just one server script in ServerScriptService. Anything pertaining to scripts in workspace are handled with CollectionService, cloning module scripts into the tagged instances and requiring them there.

As for ModuleScript architecture, I usually just cram all of my code into the main scope and return something arbitrary, like 0:

  • Parent script:
require(script.module)
  • Module script:
-- code here

return 0

If my code has dependencies on the parent script, I usually just return a function and let the parent script call it immediately:

  • Parent script:
local dependency = --arbitrary dependency

require(script.module)(dependency)
  • Module script:
--constants/services here
return function(dependency)

--code here

end

I never use these init method structures because I never know what to put in them because all of my code either depends on the state of the environment at runtime or utilizes the constants literally.

I don’t really use anymore extra abstractions than this because then it gets too overcomplicated for me and I can’t fit it all in my head, which is the very reason why anything would be organized the way it is, so you don’t have to worry about fitting it all in your head.
Any extra scripts I use are just one-liners that are placed inside instances so that my CollectionService script catches it.
Here is an example, with script.Tag.Value being a StringValue:

game:GetService("CollectionService"):AddTag(script.Parent,script.Tag.Value)
2 Likes

I don’t know how I feel about a fully eager-loaded structure. If any module experiences an error, your entire structure comes crashing down. I think it’d be better to adopt a lazy-loaded structure (i.e. a library loader).

I’ve tried using this structure before and it was an absolute nightmare as far as maintainability, organisation and freedoms went. This code strangles you to have a certain format for all ModuleScripts which may not particularly be desirable in your case.

Also, something I noticed immediately; don’t check for a LocalPlayer to differentiate the client and the server. The methods in RunService, IsClient and IsServer, exist for a reason.


@goldenstein64

Code Review has relatively strict guidelines to follow - the category name alone isn’t something you can use alone to determine the appropriate use of that category. Development Discussion is also a more casual location and is typically a lounge for development more than anything. This category is fine. Asking for advice, tips or help is a part of getting support.

2 Likes