I wanted to share Rivet, a project I have been building primarily for my own workflow.
Rivet is first and foremost a personal project. It is designed around the way I like to structure Roblox games, and I do not want it to become a heavy framework that forces a specific architecture onto every codebase. That said, it is open source, documented, published on Wally, and I think parts of it could be useful to other developers too.
The main reason I built Rivet was to solve a networking problem I kept running into:
I wanted a networking solution that preserved the intended shape of data and allowed concrete domain objects through explicit codecs instead of everything becoming ambiguous once it crossed the network boundary.
A lot of networking implementations start clean but eventually drift into loosely shaped tables, duplicated assumptions, and comments like “this should have an ItemId”. Rivet is my attempt to make those boundaries more explicit.
What Rivet Does
Rivet is a lightweight systems layer for Roblox projects. It provides a managed lifecycle, dependency ordering, explicit client surfaces, runtime contracts, codecs, and cleanup while still letting game code remain ordinary Luau modules.
The pieces I care about most are:
- Networking surfaces for explicit client/server APIs
- Contracts for runtime validation
- Codecs for domain objects
- Dependency-aware bootstrapping
- Plain ModuleScripts instead of framework-shaped code
The Networking Problem
In Roblox projects, it is easy for remotes to drift into something like this:
RemoteEvent.OnServerEvent:Connect(function(player, data)
-- What exactly is data?
-- Is ItemId always present?
-- Is Count a number?
-- Did this shape change on the client?
end)
That gets harder to reason about as a project grows. The client and server both know what they think the payload looks like, but the actual contract often lives in documentation, comments, or somebody’s memory.
Rivet tries to move those contracts next to the systems that own the behavior.
Example: Declaring a Client Surface
In Rivet, managed modules are called Units. A Unit explicitly declares what is accessible from the client.
--!strict
local Inventory = {}
Inventory.Id = "Inventory"
Inventory.Surfaces = {
Client = {
EquipItem = {
Kind = "Action",
Args = { "InventorySlot" },
},
GetItems = {
Kind = "Query",
Returns = "Item",
},
ItemAdded = {
Kind = "Signal",
Payload = { "string" },
},
},
}
function Inventory:EquipItem(player: Player, itemId: string)
print(player.Name, "equipped", itemId)
end
function Inventory:GetItems(player: Player)
return { "Sword", "Shield" }
end
return Inventory
On the client, that becomes a small API surface:
local Inventory = Rivet.Get("Inventory")
Inventory:EquipItem("Sword")
local items = Inventory:GetItems()
Inventory.ItemAdded:Connect(function(itemId)
print("Item added:", itemId)
end)
Only declared surfaces are exposed. Everything else remains private to the server.
Codecs
The part I find especially useful is codecs.
Roblox remotes already let you send tables. The issue is that tables do not communicate intent.
Consider an inventory system. On the server, you might work with an Item object that has behavior and a well-defined shape. Once it crosses a remote boundary, it usually becomes an anonymous table:
{
Id = "Sword",
Count = 3,
}
The payload contains data, but it no longer communicates what that data represents. The receiving side has to know that this table is supposed to be an item.
As projects grow, that often leads to duplicated assumptions. One system treats the payload as an item. Another treats it as an inventory entry. A third expects additional fields.
A codec makes that conversion explicit.
Rivet.Codec:Register("Item", {
Encode = function(item)
return {
Id = item.Id,
Count = item.Count,
}
end,
Decode = function(data)
return Item.new(data.Id, data.Count)
end,
})
Surfaces can then reference the codec directly:
Inventory.Surfaces = {
Client = {
GetItem = {
Kind = "Query",
Returns = "Item",
},
ItemAdded = {
Kind = "Signal",
Payload = { "Item" },
},
},
}
Now the contract is visible where the API is declared. Both sides know they are working with an Item rather than an arbitrary table.
The goal is not to send arbitrary objects through Roblox remotes. Roblox already supports tables. The goal is to make serialization explicit and keep APIs expressed in terms of game concepts rather than raw payload structures.
Bootstrap Order
The other problem Rivet solves for me is startup order.
Units are ordinary ModuleScripts. If a Unit depends on another Unit, it can declare that dependency:
--!strict
local Inventory = {}
Inventory.Id = "Inventory"
Inventory.Dependencies = { "Data" }
function Inventory:Init()
self.Data = self:Get("Data")
self.ItemsByPlayer = {}
end
function Inventory:Start()
print("Inventory is ready")
end
return Inventory
Rivet sorts Units so dependencies initialize first. Every Unit runs Init before any Unit runs Start. During shutdown, Destroy runs in reverse dependency order.
This is one of those pieces of infrastructure I found myself rebuilding repeatedly, so I moved it into a reusable system.
Not Trying To Be A Heavy Framework
I am generally not a fan of frameworks that dictate the entire structure of a project.
Rivet is not intended to be that.
The goal is simple:
- Use the parts that help
- Ignore the parts that do not
- Keep game code as ordinary Luau
- Make runtime boundaries more explicit
- Reduce framework ceremony
I like lifecycle management, dependency ordering, contracts, codecs, and plugin hooks. I just do not want those conveniences to own the entire codebase.
Possible Future Direction
One thing I am considering is splitting parts of Rivet into smaller standalone packages.
For example, the networking layer could become its own package so developers could use the networking and codec systems without adopting the rest of Rivet.
That area is especially interesting to me because it solves a problem I have encountered across multiple projects: keeping client/server contracts visible and preserving the meaning behind data that crosses the network boundary.
Current Status
Rivet is in a good place for how I personally use it. It is open source, documented, and available on Wally.
There is also a plugin API that allows external systems to hook into Rivet’s lifecycle and runtime. The goal is to make it easier to extend the framework with project-specific functionality without modifying Rivet itself.
If the project sounds useful, feel free to check it out. Feedback is welcome, especially from people who have run into similar networking or startup-order issues.
Links
GitHub: GitHub - sqrcy/Rivet · GitHub
Docs: Rivet Documentation | Rivet
Wally:
Rivet = "sqrcy/rivet@1.0.1"