Problem Statement:
There is currently an ergonomics issue with creating multiple script environments in Roblox, especially when it comes to Actors (which many assume always get their own VM).
Often times when implementing other-player prediction on the client, or when implementing server authoritative behaviors on the server, you want to simulate the reference frame of a player.
Idiomatic Luau is written with Singleton Services which are acquired through a require system like so:
local ModuleLoader = require(game.ReplicatedFirst.ModuleLoader) or shared.ModuleLoader
local Character = ModuleLoader:require("Character")
local Camera = ModuleLoader:require("Camera")
-- ...
Because module results are cached, this poses a problem when trying to simulate the reference frame of multiple players, who each need their own Camera and Character singletons, simultaneously.
Workaround:
The current workaround blessed by Roblox is to create a fresh ModuleLoader for your script environment, and for it to clone each ModuleScript before requiring. This allows it to get a fresh Module instance each time, and for the Module instance to be able to grab a reference to the ModuleLoader:
function ModuleLoader:require(name)
if not ModuleLoader._cache[name] then
local moduleTemp = self:findModule(name)
local moduleCopy = moduleTemp:Clone()
moduleCopy.Parent = script
ModuleLoader._cache[name] = require(moduleCopy)
end
return ModuleLoader._cache[name]
end
and inside the Modules:
local ModuleLoader = require(script.Parent)
local Character = ModuleLoader:require("Character")
-- ...
Is this really the right way to do it? Up until recently, this has memory leaked several KB every time a module was cloned and required.
Proposal:
In the past, the alternative to avoid this memory leak has been to wrap every ModuleScript in a function which serves as a ModuleBuilder.
Inside the modules:
return function(ModuleLoader)
local Character = ModuleLoader:require("Character")
-- ...
return Module
end
However, internally, Roblox already has a method to do this, it is just not exposed. It would be much more ergonomic to separate/sandbox our different environments if we had access to a global loadmodule
function, which works similarly to loadstring
:
local moduleBuilder = loadmodule(moduleInstance)
local Module = moduleBuilder(ModuleLoader, etc)
and in the module:
local ModuleLoader, etc = ...
-- ...
return Module
Potential Drawbacks:
There is a fear that people will use setfenv
to set the environment of a fresh Module, which de-optimizes everything, and will make client and server performance suffer.
A potential workaround for this is to change the way loadmodule
is called so that there is never a chance to call setfenv
:
loadmodule(ModuleInstance, args...)