atk422/hydrogen
A mildly opinionated luau framework/bootstrapper
Hydrogen
A mildly opinionated Luau framework/bootstrapper for Roblox. Hydrogen gives you four things:
- Systems: modules with
init/start, player (playerAdded/playerRemoving) and shutdown (stop) hooks that run in dependency order. - App: collects your systems (and network definitions) and boots them.
- Network: typed, schema-checked remote events and functions. Nothing fired during startup is lost, because events are queued until the network and each client are ready. Middleware inspects or transforms traffic, and a logger and a rate limiter are built in. Events can be unreliable, and batching and buffer encoding are there when traffic gets heavy.
- Testing: swap a system for a fake with
app:replace, and run server and client systems together in one VM withHydrogen.loopback.
Package: atk422/hydrogen on the pesde registry. Current version: 0.4.0 (see CHANGELOG.md). License: MIT.
Part 1: Using Hydrogen in a game
Install
pesde add atk422/hydrogen
pesde install
Hydrogen validates network payloads with GreenTea. Write your schemas with Hydrogen.gt, the same GreenTea copy Hydrogen checks with, so you don't need to install it separately.
Upgrading from 0.3
Most of 0.4 is new and opt-in. These changes can break a 0.3 game:
- Registering a network name twice errors (it used to warn and keep the first). Rename one, or put place-specific objects in a namespace.
serverFunction:Firetimes out after 10 seconds instead of waiting forever. Raise it per function with:SetTimeout(seconds), or use:SetTimeout(math.huge)for the old behaviour. See Server function timeouts.- The server and every client must run the same Hydrogen version. The ready handshake changed, and the server creates a third remote (
HydrogenNetworkUnreliableRemoteEvent). In practice the server and its clients always share one package, so this only matters if you vendor Hydrogen in two places.
Things you can now delete from game code:
player.Parentchecks before firing to a player: sends to players who have left are dropped.- Hand-built ordering between systems'
PlayerRemovinghandlers, such as a save-after-everything hook: use player and shutdown hooks. :: Foldercasts onWaitForChildresults passed toaddNetwork/addSystemsFolders.
Public API
local Hydrogen = require(ReplicatedStorage.roblox_packages.hydrogen)
Hydrogen.App -- App.new(), then add systems/network, then :start()
Hydrogen.define -- define a system
Hydrogen.clientEvent -- client -> server event
Hydrogen.serverEvent -- server -> client event
Hydrogen.clientFunction -- client -> server request/response
Hydrogen.serverFunction -- server -> client request/response
Hydrogen.namespace -- prefix network names, e.g. Hydrogen.namespace("Lobby").clientEvent("Play")
Hydrogen.useMiddleware -- add middleware to every network object
Hydrogen.logger -- build a logging middleware for debugging
Hydrogen.rateLimit -- build a middleware that drops a player's messages beyond a rate
Hydrogen.DROP -- return this from a middleware to stop a message
Hydrogen.loopback -- headless tests: one VM plays the server and fake clients
Hydrogen.gt -- GreenTea, for writing schemas
Types are exported too, for annotations: Hydrogen.App, Hydrogen.Defined<T>, Hydrogen.ClientEvent<T...>, Hydrogen.ServerEvent<T...>, Hydrogen.ClientFunction<A..., R...>, Hydrogen.ServerFunction<A..., R...>, Hydrogen.MiddlewareContext, Hydrogen.Middleware<T...>, Hydrogen.AnyMiddleware, Hydrogen.LoggerOptions, Hydrogen.RateLimitOptions, Hydrogen.Namespace, Hydrogen.Loopback and Hydrogen.AppOptions.
The names describe who fires it: a clientEvent is fired by the client and heard by the server; a serverEvent is fired by the server and heard by clients.
Systems
A system is a ModuleScript that returns Hydrogen.define(...). The constructor gets a use function for declaring dependencies.
-- ServerScriptService/Systems/Logger.luau
local Hydrogen = require(ReplicatedStorage.roblox_packages.hydrogen)
return Hydrogen.define(function(use)
local Logger = {}
function Logger:init()
-- set up state; don't talk to other systems' runtime state yet
end
function Logger:start()
-- everything is initialized and the network is live
end
function Logger.log(message: string)
print(`[log] {message}`)
end
return Logger
end)
-- ServerScriptService/Systems/Players.luau
local Hydrogen = require(ReplicatedStorage.roblox_packages.hydrogen)
local LoggerSystem = require(script.Parent.Logger)
return Hydrogen.define(function(use)
local Logger = use(LoggerSystem) -- Logger's init/start run before ours
local PlayersSystem = {}
function PlayersSystem:start()
Logger.log("players system started")
end
return PlayersSystem
end)
Rules worth knowing:
- The system's
idis the ModuleScript's name (e.g.Logger). It is read from the calling script, so calldefineat the top level of the module, once per module. Dependency warnings, cycle errors and boot errors name systems by the module's full name (e.g.ServerScriptService.Lobby.Systems.Data), so two systems calledDatacan be told apart. - Or pass the id yourself:
Hydrogen.define("Data", function(use) ... end). Do this wheneverdefineis called through a wrapper function, because the inferred id would be the wrapper's module. An explicit id is also what messages show. - Every hook is optional and called with
:(it receives the system asself). use(other)must be called synchronously inside the constructor. It records the dependency and returnsother(typed without its lifecycle fields, so you can't callinit/starton it by accident).- Dependencies must also be added to the same app, otherwise Hydrogen warns
System X depends on system Y which was not added to the app. - Circular
usechains error atapp:start()with the cycle printed:Cyclic system dependency: A -> B -> A. - An error in
initorstartstill stops the boot, but is reported as[HYDROGEN] System <full name> failed in start: <error and traceback>. - Systems with no dependency relationship start in the order they were added.
Player and shutdown hooks
Systems can also define playerAdded, playerRemoving and stop:
function Data:playerAdded(player: Player)
-- load the profile
end
function Data:playerRemoving(player: Player)
-- save the profile
end
function Data:stop()
-- flush anything left before the server shuts down
end
Hydrogen calls them in dependency order, so you don't depend on the order Roblox runs PlayerRemoving connections in:
playerAddedruns on each system in dependency order (dependencies first), for players already in the game whenapp:start()finishes and for everyone who joins later.playerRemovingruns in reverse dependency order. A system thatusesDatagets itsplayerRemovingbeforeDatasaves, so it can still record a forfeit or a last score.stopruns in reverse dependency order on shutdown. On the server,app:start()bindsapp:stop()togame:BindToClose.stopfirst runsplayerRemovingfor every player still in the game, waits until everyplayerRemovinghook has returned, then calls thestophooks.- The hooks for one player run one after another in a single thread, so a hook that yields (a DataStore call) delays the next system's hook for that player. Different players don't wait for each other.
- If a player leaves while their
playerAddedhooks are still running,playerRemovingwaits for them to finish. Every system seesplayerAddedbeforeplayerRemovingfor the same player, and each exactly once. - A hook that errors is reported with
[HYDROGEN] <hook> failed in system <full name>and the remaining systems still run, so one broken system can't stopDatafrom saving. - A system added after
app:start()getsplayerAddedfor players already in the game. - The player hooks run on the client too (for every player, including the local one).
stopis only bound on the server; on the client you can callapp:stop()yourself.
Network definitions
Declare network objects in shared modules that both the server and client require. Each object needs a unique name and a GreenTea schema.
-- ReplicatedStorage/Network/Chat.luau
local Hydrogen = require(ReplicatedStorage.roblox_packages.hydrogen)
local gt = Hydrogen.gt
return {
-- client -> server
SendMessage = Hydrogen.clientEvent("SendMessage", gt.string()),
-- server -> client(s); several values are allowed
MessagePosted = Hydrogen.serverEvent("MessagePosted", gt.string(), gt.string()),
-- client -> server, returns a value (args and returns wrapped in gt.args / gt.returns)
GetHistory = Hydrogen.clientFunction("GetHistory", gt.args(gt.number()), gt.returns(gt.array(gt.string()))),
-- server -> one client, returns a value (curried: args first, then returns)
Ping = Hydrogen.serverFunction("Ping", gt.number())(gt.number()),
}
| Kind | Fire side | Fire methods | Listen side | Listener |
|---|---|---|---|---|
clientEvent | client | :Fire(...) | server | :On(function(player, ...) end) |
serverEvent | server | :Fire(player | {players}, ...), :FireAll(...), :FireExcept(player | {players}, ...) | client | :On(function(...) end) |
clientFunction | client | :Fire(...) → returns | server | :On(function(player, ...) return ... end) |
serverFunction | server | :Fire(player, ...) → returns | client | :On(function(...) return ... end) |
Namespaces
Hydrogen.namespace(prefix) returns the four constructors with every name prefixed by prefix/. Use it when several places share code, so a place's Play can't collide with a common Play:
local Lobby = Hydrogen.namespace("Lobby")
return {
Play = Lobby.clientEvent("Play", gt.string()), -- sent as "Lobby/Play"
Joined = Lobby.namespace("Queue").serverEvent("Joined", gt.number()), -- sent as "Lobby/Queue/Joined"
}
The prefixed name is the object's name everywhere: on the wire, in context.name and in the logger.
Every :On returns a disconnect function. Events can have many listeners; a function has one handler (calling :On again replaces it).
Unreliable events
For data that the next message replaces anyway, such as a per-tick frame, call :Unreliable() on a serverEvent or clientEvent. It returns the event, so it chains onto the definition:
BattleFrame = Hydrogen.serverEvent("BattleFrame", gt.buffer()):Unreliable(),
The sender then uses an UnreliableRemoteEvent: messages can be lost or arrive out of order, and Roblox limits each one to about 900 bytes. Unreliable events are never queued. Anything sent before the network starts, or to a player whose client hasn't finished starting, is dropped. Only the sending side needs :Unreliable(), but calling it in the shared definition is simplest.
Server function timeouts
serverFunction:Fire waits for the client to answer, and a client (an exploiter, or one that just left) can simply never answer. So Fire errors with [HYDROGEN] <name> timed out after 10s waiting for <player> after 10 seconds by default. Change it per function:
Confirm = Hydrogen.serverFunction("Confirm", gt.string())(gt.boolean()):SetTimeout(30),
:SetTimeout(math.huge) waits forever, which lets a client hang the calling thread for good. Call server functions from their own thread (task.spawn) if a slow client shouldn't hold up the rest of a system.
Payloads are checked against the schema on both ends:
- Firing with values that don't match the schema errors immediately.
- Receiving an event whose payload doesn't match is dropped with a
[HYDROGEN] Dropped invalid <name> from <player>warning; listeners never see it. This is what protects the server from exploiters sending arbitrary data. - Functions check arguments and return values on both ends. A bad request errors back to the caller.
- Events arriving with an unknown network id are dropped with a warning.
Usage inside a system:
-- server
function ChatServer:start()
Chat.SendMessage:On(function(player, text)
Chat.MessagePosted:FireAll(player.Name, text)
end)
end
-- client
function ChatClient:start()
Chat.MessagePosted:On(function(author, text)
print(`{author}: {text}`)
end)
Chat.SendMessage:Fire("hello")
end
Middleware
Middleware sees, and can replace, every payload going over the wire. It has this shape:
function(context: Hydrogen.MiddlewareContext, ...): ...
-- return the values to pass on (return ... to leave them unchanged)
return ...
end
context tells you what's happening:
| Field | Value |
|---|---|
name | the network object's name |
kind | "ClientEvent", "ServerEvent", "ClientFunction" or "ServerFunction" |
direction | "outgoing" (about to be sent) or "incoming" (just received) |
payload | "event", "request" or "response" |
player | on the server, the single player involved (sender, or function target) |
players | on the server, every target of an outgoing serverEvent |
To stop a message instead of passing it on, return Hydrogen.DROP. A dropped event never reaches the remote or the listeners. A dropped function request or response errors, because the caller is waiting for an answer.
Attach it per object or globally. Each call returns a function that removes the middleware again.
Chat.SendMessage:Use(middleware) -- events: runs on send and on receive
Chat.GetHistory:UseRequest(middleware) -- functions: the arguments
Chat.GetHistory:UseResponse(middleware) -- functions: the return values
Hydrogen.useMiddleware(middleware) -- every network object
The same middleware runs on both sides, so check context.direction when it should only act one way. Middleware is layered like an onion: outgoing data runs through object middleware in the order added, then global middleware; incoming data runs through global middleware, then object middleware, each in reverse order. That way an encode/decode pair undoes itself correctly.
Schemas describe your app-level values: they're checked before outgoing middleware and after incoming middleware. A middleware that changes the wire format must convert back on the receiving side.
Because incoming middleware runs before validation, it receives whatever the other side sent, regardless of the Luau types. On the server that means data from possibly malicious clients: check types before using values in incoming middleware.
Logging traffic
Hydrogen.logger(options?) builds a middleware that prints every message and passes it through unchanged. Add it on both server and client while debugging:
Hydrogen.useMiddleware(Hydrogen.logger())
Output looks like:
[HYDROGEN] -> SendMessage: "hello"
[HYDROGEN] <- SendMessage from Player1: "hello"
[HYDROGEN] -> MessagePosted to 3 players: "Player1", "hello"
[HYDROGEN] <- GetHistory request from Player1: 10
Options:
filter: (context) -> boolean: returnfalseto skip a message, e.g.function(context) return context.name ~= "MouseMoved" end.output: (message: string) -> (): where lines go. Defaults toprint.
Because it's global middleware, the logger shows what is actually on the wire, after your own outgoing middleware and before your own incoming middleware.
Rate limiting
Schemas stop bad data, not floods. Hydrogen.rateLimit(options) builds a middleware that drops a player's messages once they go over a rate. Each player has a separate allowance for each network object:
Hydrogen.useMiddleware(Hydrogen.rateLimit({ perSecond = 20 })) -- every object
Remotes.PlaceTower:Use(Hydrogen.rateLimit({ perSecond = 5, burst = 10 })) -- one event
Remotes.GetShop:UseRequest(Hydrogen.rateLimit({ perSecond = 2 })) -- one function
Options:
perSecond: number: messages allowed per second, refilled continuously.burst: number?: how many may arrive at once before the rate applies. Defaults toperSecond.onLimit: (context) -> ()?: called for each dropped message, e.g. to log or kick. Nothing is logged by default.
It only limits what the server receives from clients (incoming events and function requests). Responses and everything on the client pass through. A limited function request errors back to the client.
Batching and buffer encoding
Both are off by default, and the plain path above doesn't change. Turn them on when frequent traffic, such as a per-tick frame event, shows up in the network stats. Batching removes the per-call overhead Roblox adds to every remote call. Encoding shrinks each payload. In both cases GreenTea still validates what arrives, and middleware still sees plain values.
Batching
local app = Hydrogen.App.new({ batching = true })
With batching on, Fire doesn't send right away. Hydrogen collects events per target (each player on the server, the server on the client) and sends them on Heartbeat, one remote call per target per frame. The receiver unpacks the call and runs the events in the order they were fired. Each event runs in its own thread, as separate remote calls would, so a listener that yields or errors doesn't hold up the rest.
- Reliable and unreliable events batch separately. Roblox limits an
UnreliableRemoteEventmessage to about 900 bytes, so an unreliable batch is split to stay under an estimated 900 bytes. An unreliable event too big to share a call is sent on its own, as it would be without batching. - Calling a function sends the events already waiting for that target first, so they arrive before the call.
- Queuing doesn't change. Events fired before the network starts are queued, events for a player whose client hasn't started are held until its handshake, and sends to players who left are dropped. That includes a player who left between
Fireand the flush. - Only the sender's setting matters, because receivers always understand batches. Still, turn it on for both apps so both directions batch.
- Under
Hydrogen.loopback()batching is skipped and events are sent right away. Loopback runs every fake client in one VM, so a single client-side batch would mix their events. Loopback tests your systems' logic; the batching tests cover the wire format.
Trade-offs: every event waits until the end of the frame, which adds up to one frame of latency. A batch is unpacked into one thread per event on the receiving side, the same count as unbatched. And a frame with one event to a target gains nothing: that event is sent as itself.
Why an App.new option: batching changes how the whole network runtime sends, and that runtime is shared by everything on one side (see Booting the app). It has to be decided once, before the network starts, like debug. A per-event switch would let events fired in the same frame arrive in a different order than they were fired.
Buffer encoding
local Unit = gt.table({
id = gt.number({ integer = true, range = "[0, 65535]" }),
position = gt.Vector3(),
hp = gt.number({ integer = true, range = "[0, 255]" }),
})
BattleFrame = Hydrogen.serverEvent("BattleFrame", gt.array(Unit)):Unreliable():Encode(),
:Encode() works on all four kinds (clientEvent, serverEvent, clientFunction, serverFunction; for functions it covers the arguments and the return values). The payload is packed into a buffer laid out by the schema:
| Schema | Packed as |
|---|---|
gt.number() | 8-byte float |
gt.number({ integer = true, range = ... }) | the smallest of 1, 2 or 4 bytes that holds the whole range, else 8 |
gt.string(), gt.buffer() | length (1 byte below 128) + bytes |
gt.boolean() | 1 byte |
gt.Vector3() | 12 bytes |
gt.CFrame() | 48 bytes, lossless |
gt.array(T) | count + items |
gt.table({ ... }) | the fields in key order, without the names |
gt.dictionary(K, V) | count + keys and values |
gt.optional(T) | 1 byte + T when present |
gt.literal(value) | nothing |
several values, gt.vararg(T) | each in order (varargs with a count) |
Anything else is sent as a plain Luau value beside the buffer, so every schema can be encoded; those values just don't shrink. That covers unions, gt.any(), Instances, EnumItems, Color3 and tables with both fields and an indexer.
- Validation doesn't change. The receiver decodes, runs incoming middleware, then checks the schema. A message that can't be decoded is dropped with
[HYDROGEN] Dropped undecodable <name>; for a function, the call errors. - Middleware, including the logger, sees plain values on both sides. Encoding happens after outgoing middleware and decoding before incoming middleware.
- Both sides must call
:Encode(), so put it in the shared definition. A side that doesn't will reject the other side's messages. - Encoded tables carry only the keys the schema declares. GreenTea tables accept extra keys, so an extra key gets through unencoded but is lost when encoded.
- A sized integer outside its range errors when encoding. That can only happen if outgoing middleware changes a value after it was validated.
- Encoding and decoding run in Luau on every message, so they cost more CPU than handing values to the remote. They pay off for large or frequent payloads (arrays of numbers, vectors and CFrames) and do little for an occasional short string.
Why a method on the object: the wire format is part of the definition, both sides need the same one, and the shared definition is where both sides already agree. It also chains like :Unreliable(), so you encode the hot events and leave the rest alone.
Compared with Blink or Zap, which generate code ahead of time, Hydrogen packs at runtime from the schema. Each event in a batch carries its own buffer, and ids and counts travel as plain numbers. Expect most of the size savings, not all of them.
Booting the app
Run one app on the server and one on each client. Hydrogen supports one app per side. The dependency graph and the network runtime are shared by the whole Luau VM, so a second app on the same side shares them instead of getting its own. The exception is a test under Hydrogen.loopback, where a server app and a client app can share one VM.
-- ServerScriptService/Main.server.luau
local Hydrogen = require(ReplicatedStorage.roblox_packages.hydrogen)
local app = Hydrogen.App.new()
app:addNetwork(ReplicatedStorage.Network) -- requires every ModuleScript under it
app:addSystemsFolders(ServerScriptService.Systems) -- collects every system under it
app:start()
-- StarterPlayerScripts/Main.client.luau
local Hydrogen = require(ReplicatedStorage.roblox_packages.hydrogen)
local app = Hydrogen.App.new()
app:addNetwork(ReplicatedStorage.Network)
app:addSystemsFolders(ReplicatedStorage.ClientSystems)
app:start()
App methods:
App.new(options?): a fresh app with its own system list.options.debug = trueprints how long the boot took and how long each system'sinitandstarttook, slowest first.options.outputchanges where that goes (defaults toprint).options.batching = truesends each frame's events as one remote call per target (see Batching).app:addSystem(system)/app:addSystems({ ... }): add systems directly.app:addSystemsFolders(a, b, ...): require every ModuleScript in each instance and add the ones that are systems. An instance that is itself a ModuleScript is required too. Non-systems are skipped with a warning, and an instance with no systems at all warns.app:addNetwork(a, b, ...): require every ModuleScript in each instance (again including the instance itself if it is a ModuleScript) so their network objects get registered. If the modules' return values hold no network objects, it warns[HYDROGEN] addNetwork found no network objects in <name>; that usually means the wrong instance was passed.app:replace(original, fake): beforestart, swap a system for a fake (see Testing your systems).app:start(): boot. Systems added after this areinited andstarted immediately.app:stop(): run the shutdown sequence (see Player and shutdown hooks). Called for you byBindToCloseon the server; runs once.
Both adders take any Instance, so the result of WaitForChild can be passed without a cast. On the client, anything outside the LocalScript's own descendants may not have replicated yet when it runs, so WaitForChild it first:
local playerScripts = script.Parent
app:addSystemsFolders(script.Systems, playerScripts:WaitForChild("common"))
What app:start() does, in order:
- Sorts systems so dependencies come first.
- Calls every
init. - Starts the network. The server creates
ReplicatedStorage.HydrogenNetworkRemoteEvent,HydrogenNetworkUnreliableRemoteEventandHydrogenNetworkRemoteFunction. On the client this yields until those exist and until the server hands back any events queued for this player. - Calls every
start. - Starts the player hooks (
playerAddedfor players already in the game, then for joins) and, on the server, bindsapp:stop()toBindToClose.
Network rules (read these before debugging)
- Register every network object before
app:start(). Objects created after the network starts are ignored with[HYDROGEN] Network already initialized ignoring X registration. - The server and the client must register the same set of names. Wire ids are assigned by sorting names alphabetically on each side, so a name that exists on one side only shifts every id after it. Put definitions in shared modules and
addNetworkthe same folders on both sides. When a client connects it sends a fingerprint of its names. If it doesn't match the server's, both sides warn[HYDROGEN] NETWORK MISMATCH. - Names must be unique. Registering a name twice errors. Use
Hydrogen.namespaceto keep names from different places apart. - Events sent to a player who has left (or is leaving) are dropped. This covers sends from a
PlayerRemovinghandler, or after a yield, that would otherwise queue for a player who will never become ready. - Events fired before the network starts (e.g. in
init) are queued and sent once it does. Server events aimed at a player whose client hasn't finished starting are held and delivered on that client's handshake. - Functions are not queued. Only call
clientFunction:Fire/serverFunction:Firefromstartor later; calling one earlier errors withcalled before the network started. serverFunction:Fireinvokes a single client and yields until it answers or the timeout (10 seconds by default) runs out.- Middleware added after a message is queued does not run on it; add middleware before firing anything.
- With batching on, events go out on
Heartbeat, at the end of the frame they were fired in. A function call first sends the events already waiting for its target.
Testing your systems
Two tools let a test start real systems without a running game: app:replace swaps a dependency for a fake, and Hydrogen.loopback() stands in for the remotes so client and server systems can talk in one VM. Both work under Lune and in Studio. test/Testing.luau in this repository has runnable examples.
Faking a dependency with app:replace
local Shop = require(Systems.Shop) -- Shop does `local Data = use(DataSystem)`
local DataSystem = require(Systems.Data)
local fakeData = {
getCoins = function(player)
return 500
end,
}
local net = Hydrogen.loopback() -- see below; gives the test players and a fresh network
local app = Hydrogen.App.new()
app:addSystem(Shop)
app:replace(DataSystem, fakeData)
app:start()
-- ... exercise Shop; every Data.getCoins call it makes reaches fakeData ...
app:stop()
net:close()
What replace does:
- It must be called before
app:start(). The original is added to the app if it wasn't already. - The fake takes the original's place in the boot order: its
init,start,playerAdded,playerRemovingandstoprun where the original's would have, and the original's hooks don't run. The order comes from the original's dependencies; a fake's ownusecalls don't move it. Errors from the fake's hooks name it<original> (replaced). - While the app runs, the original table forwards to the fake. Dependents keep the table
usegave them at require time, soData.getCoins(...)andData:save(...)reach the fake. Reads and writes through it (includingself.x = ...inside the fake's methods) go to the fake. Plain modules thatrequirethe original directly see the fake too. app:stop()puts the original back, and so does a boot that errors. Stop each app at the end of the test.- Only one running app can replace a given system at a time; a second errors with
already replaced by a running app. - Don't copy a field out of a dependency in the constructor (
local getCoins = use(Data).getCoins): that keeps the original function, which a laterreplacecan't reach. Keep the table and look fields up when you call them. - A system whose table is frozen (
table.freeze) or has a locked metatable can't be replaced.
Client and server in one VM with Hydrogen.loopback
Hydrogen.loopback() replaces the remotes with in-memory ones, so one VM acts as the server and as every client. Open it before app:start(), then add both sides' systems, to one app or to a server app and a client app:
local net = Hydrogen.loopback()
local alice = net:join("Alice")
local app = Hydrogen.App.new()
app:addSystems({ ShopServer, ShopClient })
app:start() -- ShopClient's Buy:Fire("sword") reaches ShopServer as coming from alice
local bob = net:join("Bob")
net:as(bob, function()
Remotes.Buy:Fire("shield") -- with two clients, say which one is sending
end)
app:stop()
net:close()
Loopback methods:
net:join(name?): add a fake client and return its player. Apps runplayerAddedfor it. Once the network runs, the client does its ready handshake, so events the server sent it before then are delivered, as in a real game.net:leave(player): the player leaves. Apps runplayerRemoving; later sends to the player are dropped.net:players(): the players still in.serverEvent:FireAllandFireExceptsend to these.net:as(player, fn, ...): runfnas that client. Client events and functions fired inside it come fromplayer. With exactly one client you don't needas; with several, a client send outsideaserrors.net:client(): the client the running code acts as, insideasor inside a server event or function handler running on that client. Every fake client shares the same client-side listeners, so this is how a test tells them apart.net:close(): everyone leaves, listeners and middleware added since the loopback opened are removed, and the network stops so the next test can start it again. Network names stay registered.
How it differs from real remotes:
- Messages still go through ids, middleware in both directions, schema validation, and the queue and handshake rules above. An invalid payload is dropped with the same warning.
- Delivery is immediate: when
Firereturns, listeners have already run up to their first yield. Each event handler gets its own thread, so its errors are reported as[HYDROGEN] Loopback event handler failedinstead of reaching the sender. Function errors reach the caller, as withInvokeServer. - Tables are copied and lose their metatables, as remotes do. Unreliable events are delivered reliably.
- Fake players are tables with
Name,DisplayName,UserId(negative),ParentandKick(which makes them leave). They aren't Instances, soplayer:IsA(...)and other Instance methods don't exist on them. RunService:IsServer()andIsClient()still return what the host says, so systems that branch on them see the same answer on both sides.- Under Lune, a network that an app started without a loopback is reset when the next loopback opens. In Studio, open the loopback before any app starts.
Systems are still module singletons: state a system keeps in its own tables lives across tests in the same VM. Reset it in init, or use one test file per scenario.
Design: why replace redirects the original
use(Other) runs once, when the dependent module is required, and returns the Other table. By the time a test calls replace, every dependent already holds that table. Two ways to make them reach a fake were considered:
usereturns a forwarding proxy whose target can be swapped. Dependents would reach the fake without the original being touched. But every field access from a dependent would go through a metatable in every game, test or not.selfinside the original's methods would be the proxy, soself == Databreaks and writes need forwarding too. And modules thatrequirea system withoutusewould still see the original.replaceredirects the original table (chosen). While the app runs, the original is emptied and given a metatable that forwards reads and writes to the fake;stoprestores its fields and metatable. Games that never callreplacepay nothing, and every holder of the table reaches the fake, whether it got it fromuseorrequire. The costs: the original is mutated while the app runs, frozen tables and locked metatables can't be replaced, and functions copied out of a dependency before the swap keep pointing at the original.
Replacements belong to an app, but act on the whole VM while that app runs. Systems are constructed once per VM, at require time, so every app shares one Data table; a swap can't be visible to one app and not another. An app records its replacements, applies them at start and undoes them at stop, and two running apps can't replace the same system.
The dependency graph and network runtime stay module-global. Moving them into each app wouldn't isolate apps, because the systems and network objects themselves are module singletons. Real isolation would need define to build a fresh system per app, so require(DataSystem) would return a definition instead of the system, which breaks every game that calls systems directly. For tests, app:stop() and loopback:close() undo what a test changes instead.
Part 2: Working on Hydrogen itself
This section is for contributors and coding agents.
Toolchain
- pesde
^0.7for packages and scripts. It also provides the Lune runtime (^0.10.2) used by tests. - Rokit installs Rojo (
rokit install, version pinned inrokit.toml). - Editor: the luau-lsp VS Code extension with its Studio plugin enabled (
.vscode/settings.json).sourcemap.jsonfeeds it Rojo paths.
Setup:
pesde install
Run the tests
pesde run test
Expected output ends with Tests Ran: 79 | Passed: 79 and status: OK. A few [HYDROGEN] warnings in the output are expected; tests exercise those paths. The command exits with code 1 on failure. GitHub Actions runs the suite on every push to main and every pull request (.github/workflows/ci.yml). The setup step installs Lune explicitly, because pesde run needs a lune executable on the PATH.
Layout
src/
init.luau public API and exported types
App.luau App: collects systems, runs init -> Network.init -> start, player and stop hooks
Definition.luau define/use, system ids, dependency graph wiring, replace redirects, lifecycle-stripping type function
Graph.luau generic dependency graph with topological sort and cycle detection
Network/
init.luau exports constructors and Network.init
Runtime.luau remotes, name->id mapping, queues, batching, ready handshake, dispatch
Middleware.luau middleware context/types, onion ordering, global middleware, logger
Codec.luau schema-driven buffer encoding for :Encode()
Loopback.luau in-memory remotes and fake players for headless tests
ClientEvent.luau ServerEvent.luau ClientFunction.luau ServerFunction.luau
test/
_run.luau Lune entry point: mocks Roblox globals, patches require, runs suites
App.luau Definition.luau Graph.luau Network.luau
Testing.luau app:replace and Hydrogen.loopback, used the way a game's tests would
default.project.json Rojo project for development (see below)
pesde.toml package manifest, scripts, dependencies, version
How the tests run
Tests run outside Roblox under Lune. test/_run.luau:
- builds a DataModel with
@lune/robloxand sets thegame/Instance/taskglobals; - stubs
RunServicesoIsClient()andIsServer()both returnfalse. Real remotes and thePlayers/BindToCloseconnections are never exercised.test/Testing.luauruns the network end to end throughHydrogen.loopback(); the other suites callapp:_addPlayer/app:_removePlayerwith fake players; - overrides
requireso@self/and relative paths resolve from the calling file, and redirects GreenTea to its pesde install path. Lune can't require Instances, so a test sets_G.instanceModules[moduleScript] = valueandrequire(moduleScript)then returnsvalue; - calls each test module, runs frktest, then exits with
process.exit. The explicit exit matters: the server function timeout test yields the main thread and resumes it from a timer, and without the exit Lune can keep running after the last suite.
To add a test file: create test/Thing.luau that returns a function registering test.suite(...) blocks (copy the shape of test/Graph.luau), then add require("./Thing")() next to the others in test/_run.luau. The Network and Testing suites reset Runtime state in test.on_suite_enter; do the same if you touch Runtime. The Testing suite also stops its apps and closes its loopbacks in test.on_test_leave, so a failed test doesn't leak a running network into the next one.
Conventions
- Strict mode is on for every file through
.luaurc/src/.luaurc. Keep code type-checking clean. - Requires:
@self/...frominit.luaufiles,./...or../...elsewhere. GreenTea is loaded by relative path (./roblox_packages/gtfromsrc/init.luau,../../roblox_packages/gtfromsrc/Network/*). - Those paths rely on
srcandroblox_packagesbeing siblings. That is the layout pesde generates for consumers, anddefault.project.jsonmirrors it for development. Don't moveroblox_packagesinsidesrc. After changingdefault.project.json, regenerate the sourcemap withrojo sourcemap default.project.json --output sourcemap.json. - Runtime warnings and errors are prefixed with
[HYDROGEN]. - Match the indentation of the file you edit (most use tabs; some
Network/*files use 4 spaces). - Public API changes go through
src/init.luau. Update this README when the API or a network rule changes.
Releasing
- Bump
versioninpesde.tomland runpesde installsopesde.lockpicks it up. - Add the release to
CHANGELOG.md. - Run the tests, then check what will be uploaded:
pesde publish --dry-run
- Publish:
pesde publish
Only files listed under includes in pesde.toml are published: the source, pesde.toml, README.md, LICENSE and CHANGELOG.md. default.project.json is deliberately left out so pesde generates the consumer layout.