This post is less of a insertable module that “just works” and more of an example on how to implement a particular communication protocol that you can adapt for your own uses. I will attempt to explain the gist of the protocol using a simplified example.
Why would I want to communicate between modules without bindable events?
-
Pass tables by reference instead of deep copy. For large tables, this may save significant amounts of time and memory
-
Preserve relationship of transferred object to its metatable i.e. when passing an object that previously had setmetatable(object, meta) called
-
Pass cyclic tables (such as passing the metatable itself, where oftentimes metatables refer to itself using __index)
-
Pass mixed tables (string and numerical keys)
-
Immediate synchronous communication (unlike bindables when Deferred Signals are on)
-
Cuts the overhead of bindable events (saves time if very frequent communication between 2 modules is needed, or for time-sensitive communication like in a pre-RenderStep bound thread)
Let me begin by talking about one way communication:
One way communication can pretty simply be done without bindable events by having one module A require() the other module B, and invoking a “receiving” callback function defined in B.
--in module A
local MessageFromA = "Hi this is A"
local B = require(game.ModuleB)
B.ReceiveMessage(MessageFromA)
--in module B
function B.ReceiveMessage(...)
print("Message Received!", ...)
end
When the code in Module A is executed, it will invoke B’s ReceiveMessage function, which will print “Message Received! Hi this is A”.
This works in one direction A->B, but what if module B wants to communicate with Module A? If we attempt to now
--in module B
local A = require(game.ModuleA)
We get a “cyclic dependency error”. This is because a module cannot require a module, that also requires itself, since it will go around and around.
How do we get around this? Well a trivial solution adding onto the code we had before is just to have A send itself to B, and we’ll just assume that the first message B receives will always be a reference to A.
--in module B
local IsFirstMessage = true
local ReferenceToA = nil
function B.ReceiveMessage(...)
if IsFirstMessage then
ReferenceToA = ...
IsFirstMessage = false
return
end
print("Message Received!", ...)
end
Now if in module A, a callback is defined such as
function A.ReceiveMessage(...) <do something> end
After A sends its first message to B, B will then be able to send messages to A by using its reference to A to invoke that callback.
However, what if B needs to send a message first?
What if B forgets to store a reference to A after receiving the first message?
What if another Module C sends a message to B before A does, and now B holds the wrong object? What if I have modules A,B,C,D,E,F,G,H… and they all need to communicate with eachother?
As you can see, this trivial method of 2 way communication quickly breaks down the instant you have more than two modules. So what is a better solution?
Have a topmost level “communication” module that defines a “message sending” function. All other modules will be below this one, and the communication module will require() all other modules, then place a reference to itself inside each of the lower modules
Simplified example:
local comm = {}
--this is the topmost module
--no other modules should require(comm) or else cyclic dependency error
local A = require(game.A) --each of these modules should
local B = require(game.B) --define its own ReceiveMessage function
local C = require(game.C)
A.comm = comm
B.comm = comm --this is how the lower modules get a reference to comm
C.comm = comm
function comm.SendMessage(sender: string, receiver: string, ...)
if receiver == "A" then
A.ReceiveMessage(sender, ...)
elseif receiver == "B" then
B.ReceiveMessage(sender, ...)
elseif receiver == "C" then
C.ReceiveMessage(sender, ...)
end
end
return comm
--main script/localscript
require(game.comm) --MUST DO THIS BEFORE the other ones
local A = require(game.ModuleA)
local B = require(game.ModuleB)
A.DoSomething()
The comm module is requiring all of the lower modules, and placing a reference to itself in each module using a string key “comm”. Now the lower modules can call SendMessage by indexing into itself using that same string key “comm” (as long as the main script require(comm) first, so it can do its thing, before any of the lower A,B,C, etc modules start sending messages)
Now, because the logic in the comm module (and thus creation of the comm string key in each of the lower modules) doesn’t occur until runtime, when you are editing the lower modules, the typechecker will not recognize the “comm” member, since technically it doesn’t exist (yet)
--in module A
function A.ReceiveMessage(sender, ...) <stuff goes here> end
function A.DoSomething()
A.comm.SendMessage("A", "B", "hi from A") --this will work
--but A.comm is not defined prior to runtime so no autofill
end
You can get the autofill to work for A.comm by adding a type (although it has no effect on the actual code execution). This will raise a warning if --strict typechecking is on.
type comm = {SendMessage: (SenderName: string, RecipientName: string, ...any)->()}
local UselessVarToTrickTypechecker :comm
A.comm = UselessVarToTrickTypechecker --this is nil before runtime
The implementation of comm can be made more flexible for an arbitrary number of modules by placing all the module scripts in a table that it loops through, and defining some sort of identifier inside each module such as a name or id, then place a reference to each module inside of a table that comm.SendMessage has access to:
--in module A, similar thing in module B
A.name = "A"
local comm = {}
local ModuleScripts = {
game.ServerScriptService.ModuleA,
game.ServerScriptService.ModuleB,
--add more here as needed
}
local ModulesTable = {}
for k, ModuleScript in ModuleScripts do
local module = require(ModuleScript)
module.comm = comm
ModulesTable[module.name] = module --define a name key inside each module
end
function comm.SendMessage(SenderName :string, RecipientName :string, ... :any)
local recipient = ModulesTable[RecipientName]
if not recipient then warn("Module named", RecipientName, "not found") return end
local callback = recipient.ReceiveMessage
if not callback then warn("Module named", RecipientName, "has no callback") return end
callback(SenderName, ...)
end
return comm
The ReceiveMessage callback is analogous to bindable.Event:Connect( callback )
and SendMessage is similar to bindable:Fire()
Here’s a file to play around with:
comm.rbxl (49.0 KB)