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")
    An image showing a Folder Remotes in Replicated Storage, with four different Remote Events
    As god intended
luau
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"
    An image of just a single Remote Event under Replicated Storage
    God Remotes: For the masochists with a death wish
luau
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.

Alt text
This basic example from Legacy 3 in 2013 should give you enough of an 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...

*.server.tsts
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
});
*.client.tsts
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.

*.server.tsts
import Net from "@rbxts/net";
const DoThing1 = Net.Server.Event<[arg1: string]>("DoThing1");
DoThing1.SetCallback((player, arg1) => {
    // Do thing 1
});
*.client.tsts
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.

shared/remotes.tsts
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 Ids to you through that) - it really simplified things.

*.server.tsts
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

*.client.tsts
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

shared/remotes.tsts
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.

ts
@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 of ServerToClientEvent, ClientToServerEvent, ServerAsyncFunction etc. etc. This should be handled by our builder. If we need a return value, that can be specified via WhichReturnsAsync.
  • 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:

shared/remotes.tsts
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.

ts
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:

shared/networking/example-namespace.tsts
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:

shared/remotes.tsts
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 Limitingts
// 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!