When I started my networking library RbxNet back in 2020, it originally was meant to serve as a replacement for my old module ModRemote for use in my game Zenerith.
Remotes: two 'methods' to use
Roblox has two ways of handling networking - via two instances:
RemoteEvent
- akin to a one way 'message'. You give something without expecting anything back.RemoteFunction
- akin to a two-way message, you send something and expect something in return
With those - people would end up doing one of two things when trying to do networking back in those days:
- The per-instance approach (the "proper way") As god intended
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local DoThing1 = ReplicatedStorage.Remotes.DoThing1
local DoThing2 = ReplicatedStorage.Remotes.DoThing2
local DoThing3 = ReplicatedStorage.Remotes.DoThing3
-- Then you'd handle each event separately e.g.
DoThing1.OnServerEvent:Connect(function(arg1)
-- do thing 1
end)
DoThing2.OnServerEvent:Connect(function(arg2, arg3)
-- do thing 2
end)
DoThing3.OnServerEvent:Connect(function(arg4, arg5, arg6)
-- do thing 3
end)
- The "God Remote" God Remotes: For the masochists with a death wish
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local DoThing1 = ReplicatedStorage.Remote
DoThing1.OnServerEvent:Connect(function(name, ...)
if name == "DoThing1" then
local arg1 = ...
-- Do thing 1
elseif name == "DoThing2" then
local arg2, arg3 = ...
-- Do thing 2
elseif name == "DoThing3" then
local arg4, arg5, arg6 = ...
-- Do thing 3
end
end)
^ I think already with the above you can see how that's much uglier and messy.
With the second option, you have what I call a "god remote" after the infamous God Object - which is 100% a code smell - (not to mention can be a bandwidth nightmare).
This is a trap I also fell into once upon a time too. It was just easier. Typing out those remotes by hand was painful... and well, if it's easier doesn't always mean it's the right idea.
And I found out the hard way the performance implications that method had first hand. Not to mention how awful in general having a gargantuan amount of code for networking in once place LOOKS. One massive function calling other functions. Yikes.
ModRemote - then eventually RbxNet
So that's why I originally wrote ModRemote for a game I later developed called Heroes Legacy. The ease of using a "create/manage it with code" model with the advantage of having it properly split up like the first method. The way Roblox designed the instances! - I could still reference the given functions or events just using an Id.
RbxNet came later on when Roblox TypeScript became a thing. It was the natural progression of making networking easier. Now you could actually define what types your remotes could use, and it was a lot more object-oriented looking!
How RbxNet started
In the original days of RbxNet, it was just essentially my old library with a different look... unfortunately this became a nightmare for maintenance, especially with a larger game...
import Net from "@rbxts/net";
const DoThing1 = Net.Server.Event<[arg1: string]>("DoThing1");
DoThing1.SetCallback((arg1) => {
// Do thing 1
});
const DoThing2 = Net.Server.Event<[arg2: string, arg3: number]>("DoThing2");
DoThing2.SetCallback((arg2, arg3) => {
// Do thing 2
});
const DoThing3 = Net.Server.Event<[arg4: number, arg5: boolean, arg6: string]>("DoThing3");
DoThing3.SetCallback((arg4, arg5, arg6) => {
// Do thing 3
});
import Net from "@rbxts/net";
const DoThing1 = Net.Client.Event<[arg1: string]>("DoThing1");
const DoThing2 = Net.Client.Event<[arg2: string, arg3: number]>("DoThing2");
const DoThing3 = Net.Client.Event<[arg4: number, arg5: boolean, arg6: string]>("DoThing3");
Uh oh. Did you just see what I saw? two different sources of code - using the same remotes... but even if you were to simplify this by using something like an enum - you still had the issue of having to declare the remotes in two places.
Two places. That's enough for having a woopsie mistake.
import Net from "@rbxts/net";
const DoThing1 = Net.Server.Event<[arg1: string]>("DoThing1");
DoThing1.SetCallback((player, arg1) => {
// Do thing 1
});
import Net from "@rbxts/net";
const DoThing1 = Net.Server.Event<[arg1: number]>("DoThing1");
DoThing1.SendToServer(420); // 😆 ooops I forgot this was actually a string when writing the client code - OOHHHH NOOOOOO
Oh and even worse, if you didn't create it on the server it didn't exist... so good luck with the errors around that...
I think you get the general idea.
Moving to 'Definitons' - a single source of truth - the network object model (v2.0)
When developing my game Zenerith - I ran into many issues trying to maintain a huge list of remotes with that API. It wasn't great.
So what I came up with to address the fragmentation was called the "Network Object Model" (NOM) or simply "Definitions" for short. The idea was that you'd declare one 'source' of what remotes your game has, and that would be the interface of which you'd access those remotes.
import Net from "@rbxts/net";
const Remotes = Net.Definitions.Create({
DoThing1: Net.Definitions.Event<[arg1: string]>(),
DoThing2: Net.Definitions.Event<[arg2: string, arg3: number]>(),
DoThing3: Net.Definitions.Event<[arg4: number, arg5: boolean, arg6: string]>(),
});
export default Remotes;
Unlike version 1 of the API, we could just use Get(id)
(and it would suggest the available list of Id
s to you through that) - it really simplified things.
import Remotes from "shared/remotes";
Remotes.Server.Get("DoThing1").SetCallback((player, arg1) => {
// Do thing 1
});
Remotes.Server.Get("DoThing2").SetCallback((player, arg2, arg3) => {
// Do thing 2
});
Remotes.Server.Get("DoThing3").SetCallback((player, arg4, arg5, arg6) => {
// Do thing 3
});
On the client then, it was just a matter of doing
import Remotes from "shared/remotes";
const DoThing1 = Remotes.Client.Get("DoThing1")
DoThing1.CallServerAsync("Hi!"); // Trying to do a number here, it would throw a type error
// .... (at least in TypeScript! - we'll get to Luau later...)
A fun fact, when I started at easy.gg - my current employer - one of my first things was to upgrade Islands to use this new definitions model! It was the first time I actually realised the magnitude of how useful something like this had become...
Creating a stricter network object model (v3.0)
Further developing definitions - you couldn't know if DoThing1
was meant to be called by the server, or by the client. It could go both ways. So that release introduced much more "explicit" APIs
import Net from "@rbxts/net";
const Remotes = Net.Definitions.Create({
// This can only be called by a server, and recieved by clients
DoThing1: Net.Definitions.ServerToClientEvent<[arg1: string]>(),
// This can only be called by a client, and recieved by the server
DoThing2: Net.Definitions.ClientToServerEvent<[arg2: string, arg3: number]>(),
// The old behaviour 'Event' had - both ways used the same parameters etc.
DoThing3: Net.Definitions.BidirectionalEvent<[arg4: number, arg5: boolean, arg6: string]>(),
});
export default Remotes;
The need for stricter type enforcement in the NOM (v4.0)
Finally, to the point of all this. Earlier this year I had an ephiphany. Even if I'm aware of the requirement of validation, and why RbxNet has type check middleware in the first place... one fateful day woke me up to the realization that it was too easy to make the mistake of relying on just the types and not remembering to validate... Oh dear.
It took one fateful mistake. Yes, just like the sign exploit... and you have dupes - again. Yes, this was a great time.
@Service()
class PrivacyService implements OnStart {
/// ...
public onStart() {
Remotes.Server.OnFunction("ChangePrivacyMode", (player, req) => {
const islandModel = ...;
if (!islandModel) return false;
return this.changePrivacyMode(player, islandModel, req.toggleMode); // Oh no...
});
}
public changePrivacyMode(player: Player, islandModel: IslandModel, toggleMode: boolean) {
const islandData = ...;
islandData.privacyMode = toggleMode; // Oh FUCK.
/// ...
return true;
}
/// ...
}
Oh, fuck. If you're not familiar with how exploitable Roblox is, it's easy to make a mistake like this. Just like the sign exploit, all it took was an exploit that put a value that can't be saved to a DataStore... and yeah. Deja Vu.
With that now on my mind, and wanting to for a while to move to a more friendly API for RbxNet users, I've decided it's time to rethink how RbxNet should work.
- The new API should be friendly, readable.
- The new API should enforce type validation. This would also benefit Luau users as well, since they don't even get great types from Net yet (due to limitations of the Luau type system)
- The new API should be compositional - builders - you should be able to see from reading the definitions what each remote "brings" to the table.
An early sneak peak at v4.0
To begin with, I'll introduce a few "changes" to how our Networking Object Model (NOM 🐹) will be defined.
Net has evolved since 1.0, the methods are now definitions based - therefore that should be our primary interface.
- Note: These aren't final API names yet, and may change, but the idea should still stay the same...
- Introducing two new top-level functions
Net.BuildDefinition()
- This will return a builder for any NOM Namespace. Including sub namespaces! - all in one place!Net.Remote(...argumentTypeCheckers)
- Yes. One function. Reducing the clutter ofServerToClientEvent
,ClientToServerEvent
,ServerAsyncFunction
etc. etc. This should be handled by our builder. If we need a return value, that can be specified viaWhichReturnsAsync
.
- Server/Client context is moved to the definition itself.
AddServerOwned
,AddClientOwned
. The former of course means the server owns it (therefore either invokes it, or handles the callbacks) - or the later owns it and the client handles invoking and callbacks! - Middleware will be more than just a server thing, there will likely be client middleware too - as well as invoking middleware!
- These explicit types may allow for serialization and type packing in future
Declaring a top-level NOM namespace
Using t (a type checking library) - we can now do the following:
import Net from "@rbxts/net";
import t from "@rbxts/t";
const Remotes = Net.BuildDefinition()
.AddServerOwned("DoThing1", Net.Remote(t.string)) // ServerToClientEvent
.AddClientOwned("DoThing2", Net.Remote(t.string)) // ClientToServerEvent
// And if we wanted an async function, we just explicitly state we want a return value...
.AddServerOwned("GetThing", Net.Remote(t.string).WhichReturnsAsync(t.number))
// We call build when we're finally done with building our NOM:
.Build();
And just like before, it is accessible through Remotes.Server
and Remotes.Client
.
import Remotes from "shared/remotes.ts";
// arg1 here has the validation handled FOR US! - because we explicitly added that - and it's typed!
const DoThing1 = Remotes.Get("DoThing1").SetCallback((player, arg1) => {
// Do things with arg1
});
Nesting NOM namespaces
In v2.0 an v3.0, we used Net.Definitions.Namespace
- but as stated above, with v4.0 we will still use Net.BuildDefinition
.
Unlike the former two major versions, an example of us using a namespace in our resulting NOM is done like so:
import Net from "@rbxts/net";
export default Net.BuildDefinition()
.AddServerOwned("ExampleMember", Net.Remote(t.string));
Yup. Note the lack of Build()
- because this will be built by our top-level namespace. Usage is such:
import Net from "@rbxts/net";
import exampleNamespace from "shared/networking/example-namespace.ts";
export const Remotes = Net.BuildDefinition()
.AddNamespace("ExampleNamespace", exampleNamespace) // Simple as that!
// And of course we can still do top-level remotes!
.AddServerOwned("TopLevelRemote", Net.Remote().WhichReturnsAsync(t.string)) // basically should be () => string
// ... etc
.Build();
Oh and a few minor things to mention before I wrap this up - the Remote
builder includes adding middleware in a simpler way.
// Rate limits in <= 3.0
Net.Definitions.ServerAsyncFunction([createRateLimit({
MaxRequestsPerMinute: 10
})])
// Rate limits in 4.0 - plus some upcoming API changes!
Net.Remote(...).WithRateLimit({
RequestsPerWindow: 10,
RequestWindow: Duration.minutes(1) // Net.Duration ? - tbd!
})
// or
Net.Remote(...).WithServerMiddleware(createRateLimit({
RequestsPerWindow: 10,
RequestWindow: Duration.minutes(1)
}))
Also coming soon will be SetUnreliable
- once UnreliableRemoteEvent
is a thing. 😁 Once 4.0 comes out, the documentation will cover everything!
So, in summary - I'm updating Net again after a while of being silent. You can follow the discussion around the new API here. I will be focusing on making the new version of Net as simple yet powerful as it can be... and my game Zenerith is the first victim game that will be the test of things. After being stuck in v2.2 for years, now straight to 4.0!